diff --git a/eslint.config.js b/eslint.config.js index e15391c6c..5978a2d0d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -282,8 +282,8 @@ export default tseslint.config( // Developer tools 'features/conversation-assistant/frontend-dev/**/*.{ts,tsx}', 'features/conversation-assistant/frontend-macos-client/**/*.{ts,tsx}', - 'features/image-assistant/frontend-dev/**/*.{ts,tsx}', - 'features/image-assistant/frontend-macos-client/**/*.{ts,tsx}', + 'features/video-studio/packages/media-gallery/frontend-dev/**/*.{ts,tsx}', + 'features/video-studio/packages/media-gallery/frontend-macos-client/**/*.{ts,tsx}', 'features/platform-content-tools/frontend-dev/**/*.{ts,tsx}', 'features/frontend-showcase/frontend/**/*.{ts,tsx}', ], diff --git a/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts b/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts index 737a43c1d..d37c26126 100644 --- a/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts +++ b/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts @@ -11,12 +11,22 @@ const VIDEO_API = '/proxy/video'; const IA_API = '/proxy/ia'; +export type ProcessingStage = + | 'queued' + | 'extracting_frames' + | 'scoring' + | 'encoding_clip' + | 'done' + | 'failed'; + export interface PhotoItem { id: string; filename: string; - original_url: string; // presigned S3 URL — pass as video_path to protect jobs + original_url: string; // presigned S3 URL — pass as video_path to protect jobs thumbnail_b64: string | null; // sharpest frame from first 10s, 320px JPEG preview_clip_b64: string | null; // first 4s, 320px fragmented MP4 for hover loop + processing_stage: ProcessingStage; + processing_progress: number; // 0.0 – 1.0 duration_seconds: number; file_size: number; width: number; @@ -91,6 +101,8 @@ export async function listVideoPhotos(forceRefresh = false): Promise([]); @@ -23,7 +25,7 @@ export function InvisibleProtectionsDemo(): ReactElement { const [expandedJobId, setExpandedJobId] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const pollRef = useRef | null>(null); + const jobPollRef = useRef | null>(null); const loadPhotos = useCallback((force = false) => { setPhotosLoading(true); @@ -38,19 +40,36 @@ export function InvisibleProtectionsDemo(): ReactElement { loadPhotos(); listAvailableProtections() .then(setAvailableOps) - .catch(() => setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion'])); + .catch((err: unknown) => { + console.warn('listAvailableProtections failed, using defaults:', err); + setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion']); + }); }, [loadPhotos]); + // Poll media manifest every 2s while any video is still processing + const anyProcessing = photos.some( + (p) => p.processing_stage !== 'done' && p.processing_stage !== 'failed', + ); + useEffect(() => { + if (!anyProcessing || photos.length === 0) return; + const id = setInterval(() => { + listVideoPhotos(false) + .then(setPhotos) + .catch((err: unknown) => console.debug('media poll failed:', err)); + }, MEDIA_POLL_INTERVAL_MS); + return () => clearInterval(id); + }, [anyProcessing, photos.length]); + // Poll active jobs every 5s useEffect(() => { const hasActive = jobs.some((j) => j.status === 'queued' || j.status === 'processing'); if (!hasActive) { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; + if (jobPollRef.current) clearInterval(jobPollRef.current); + jobPollRef.current = null; return; } - if (pollRef.current) return; - pollRef.current = setInterval(async () => { + if (jobPollRef.current) return; + jobPollRef.current = setInterval(async () => { const updates = await Promise.allSettled( jobs .filter((j) => j.status === 'queued' || j.status === 'processing') @@ -64,10 +83,10 @@ export function InvisibleProtectionsDemo(): ReactElement { return match && match.status === 'fulfilled' ? match.value : job; }), ); - }, POLL_INTERVAL_MS); + }, JOB_POLL_INTERVAL_MS); return () => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; + if (jobPollRef.current) clearInterval(jobPollRef.current); + jobPollRef.current = null; }; }, [jobs]); @@ -249,6 +268,7 @@ interface VideoCardProps { function VideoCard({ photo, selected, onClick }: VideoCardProps): ReactElement { const [hovered, setHovered] = useState(false); + const isDone = photo.processing_stage === 'done' || photo.processing_stage === 'failed'; return (