// pipeline-screen.jsx — the streaming/processing view. // 4 stages with real-time-feeling log streams, individual + overall progress. const TXT_PIPELINE = { vi: { building: "Đang dựng", overallProgress: "Tiến trình tổng thể", stageAComplete: "Giai đoạn A hoàn tất. Vui lòng xem lại các nhịp phân cảnh, chỉnh sửa mô tả hình ảnh/từ khóa tìm kiếm và tạo lại tài nguyên nếu cần trước khi tiếp tục.", saveScene: "Lưu phân cảnh", saving: "Đang lưu...", regenerate: "Tạo lại", regenerating: "Đang tạo lại...", continueBtn: "Tiếp tục tạo video", bulkRegen: "Tạo lại tài nguyên lỗi", startPhaseB: "Bắt đầu giai đoạn B", filters: "Bộ lọc", searchCaptions: "Tìm kiếm lời thoại...", allScenes: "Tất cả phân cảnh", unsavedEditsOnly: "Thay đổi chưa lưu", hasUnsavedEdits: "Có chỉnh sửa chưa lưu", allMedia: "Tất cả phương tiện", unsplashQueries: "Từ khóa Unsplash", aiImagePrompts: "Yêu cầu ảnh AI", videoQueries: "Từ khóa Video", showAllDetails: "Hiện tất cả chi tiết", collapseAll: "Thu gọn tất cả", sceneTitle: "Cảnh", captionLocked: "Lời thoại (Thuyết minh - đã khóa)", visualDesc: "Mô tả hình ảnh (Yêu cầu cho Agent 1)", mediaQueries: "Từ khóa tìm kiếm phương tiện", noMedia: "Không có yêu cầu phương tiện nào cho cảnh này", saveEditsBtn: "Lưu chỉnh sửa", queryLabel: "Từ khóa", promptLabel: "Lời thoại", typeLabel: "Loại", previewTitle: "Xem trước", jobTitle: "Thông tin Job", jobId: "Mã Job", status: "Trạng thái", style: "Phong cách", language: "Ngôn ngữ", aspect: "Tỷ lệ", words: "Số từ", failed: "Thất bại", closeBtn: "Đóng (Esc)", livePreview: "trực tiếp · {stage}", doneStatus: "xong", activeStatus: "đang chạy", pendingStatus: "chờ", secLabel: "giây", audios: "♪ Âm thanh", searches: "⊕ Tìm kiếm", aiPhotos: "✦ Ảnh AI", videos: "▶ Video", beatsLabel: "nhịp_{num}", sceneNumLabel: "CẢNH {num}", }, en: { building: "Building", overallProgress: "Overall Progress", stageAComplete: "Stage A complete. Please review the storyboard beats, edit visual prompts/queries, and regenerate any assets before continuing.", saveScene: "Save Scene", saving: "Saving...", regenerate: "Regenerate", regenerating: "Regenerating...", continueBtn: "Continue Video Generation", bulkRegen: "Bulk Regenerate Failed", startPhaseB: "Start Phase B", filters: "Filters", searchCaptions: "Search captions...", allScenes: "All Scenes", unsavedEditsOnly: "Unsaved changes", hasUnsavedEdits: "Has unsaved edits", allMedia: "All media", unsplashQueries: "Unsplash queries", aiImagePrompts: "AI Image prompts", videoQueries: "Video queries", showAllDetails: "Show all details", collapseAll: "Collapse all", sceneTitle: "Scene", captionLocked: "Caption (Voiceover - locked)", visualDesc: "Visual Description (Agent 1 prompt)", mediaQueries: "Media Search Queries", noMedia: "No media requests for this scene", saveEditsBtn: "Save edits", queryLabel: "Query", promptLabel: "Prompt", typeLabel: "Type", previewTitle: "Preview", jobTitle: "Job", jobId: "Job ID", status: "Status", style: "Style", language: "Language", aspect: "Aspect", words: "Words", failed: "Failed", closeBtn: "Close (Esc)", livePreview: "live · {stage}", doneStatus: "done", activeStatus: "active", pendingStatus: "pending", secLabel: "s", audios: "♪ Audio", searches: "⊕ Search", aiPhotos: "✦ AI photo", videos: "▶ Video", beatsLabel: "beat_{num}", sceneNumLabel: "SCN {num}", } }; const STAGE_TRANSLATIONS = { vi: { analyze: { name: "Phân tích kịch bản", sub: "Phân tích giọng điệu, nhịp truyện, thực thể" }, visual: { name: "Thiết kế hình ảnh", sub: "Tìm kiếm cảnh phim, tạo hình ảnh minh họa" }, post: { name: "Hậu kỳ", sub: "Cắt ghép, hiệu ứng âm thanh, đồ họa chuyển động" }, render: { name: "Xuất video & Tối ưu hóa", sub: "Mã hóa bản gốc 4K + bản web 1080p" }, }, en: { analyze: { name: "Script Analysis", sub: "Parsing tone, beats, named entities" }, visual: { name: "Visual Design", sub: "Sourcing footage, generating fills" }, post: { name: "Post-Production", sub: "Cuts, SFX, mix, motion graphics" }, render: { name: "Render & Optimize", sub: "Encode 4K master + 1080p web" }, } }; const STAGES = [ { id: "analyze", name: "Script Analysis", sub: "Parsing tone, beats, named entities", icon: "doc", duration: 4200, logs: [ "tokenizer: loaded en_core_web_trf", "split into 14 narrative beats", "detected tone: \"contemplative · documentary\"", "mood vector: { wonder: 0.82, melancholy: 0.41 }", "extracted entities: Voyager 1, Voyager 2, Cape Canaveral, NASA, 1977", "key questions identified: 3", "estimated runtime: 1m 12s @ 1.00× pacing", "scene plan emitted → 11 scenes", ], }, { id: "visual", name: "Visual Design", sub: "Sourcing footage, generating fills", icon: "frame", duration: 7000, logs: [ "querying stock: pexels, pond5, internal cache", "matched scene 01: \"cape canaveral 1977 launch\" (4 hits)", "matched scene 02: \"voyager spacecraft assembly\" (2 hits)", "matched scene 03: \"golden record etching macro\" (0 hits) — generating", "generating scene 03 with sdxl-turbo @ 1920×1080", "matched scene 04: \"cape canaveral aerial\" (6 hits)", "matched scene 05: \"voyager probe trajectory animation\"", "generating scene 06 fill: \"deep space, distant sun\"", "generating scene 09 fill: \"radio dish, slow rotation\"", "matched scene 10: \"interstellar medium plot\"", "11 / 11 scenes resolved", "color-matching across scenes → palette: warm/desaturated", ], }, { id: "post", name: "Post-Production", sub: "Cuts, SFX, mix, motion graphics", icon: "wave", duration: 5800, logs: [ "ffmpeg: trimming 11 clips to scene durations", "applying LUT: doc_warm_v3.cube", "subtitle pass: 64px Arial, white / black outline", "highlighting word-emphasis on 41 keywords", "sfx layer: \"low_rumble_bed_05.wav\" → -22 LUFS", "sfx layer: \"radio_static_short_02.wav\" @ 00:00:47", "music: \"distant_signal_120bpm.flac\" → ducked under VO", "motion graphics: lower-third title cards × 2", "ken-burns on still scenes 03, 06, 09", "loudness normalize → -14 LUFS integrated", ], }, { id: "render", name: "Render & Optimize", sub: "Encode 4K master + 1080p web", icon: "play", duration: 4400, logs: [ "render queue: h264_nvenc, 1080p, crf 18", "frame 0000 / 1728", "frame 0288 / 1728 · 16.7 %", "frame 0576 / 1728 · 33.3 %", "frame 0864 / 1728 · 50.0 %", "frame 1152 / 1728 · 66.7 %", "frame 1440 / 1728 · 83.3 %", "frame 1728 / 1728 · 100 % ✓", "writing master 4K (hevc) in background", "thumbnail extracted @ 00:00:12", "ready: skillsvideo_voyager_1080p.mp4 · 42.8 MB", ], }, ]; const STAGE_ICONS = { doc: <>, frame: <>, wave: <>, play: <>, }; function computeAssetStats(scenes) { if (!scenes || scenes.length === 0) return null; const audio = { done: 0, total: 0 }; const unsplash = { done: 0, total: 0 }; const ai = { done: 0, total: 0 }; const video = { done: 0, total: 0 }; for (let i = 0; i < scenes.length; i++) { const scene = scenes[i]; audio.total += 1; if (scene.local_audio) audio.done += 1; let imgIdx = 0; let vidIdx = 0; for (const req of (scene.media_requests || [])) { if (req.type === "unsplash") { unsplash.total += 1; if (scene.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) unsplash.done += 1; imgIdx++; } else if (req.type === "ai" || req.type === "gemini") { ai.total += 1; if (scene.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) ai.done += 1; imgIdx++; } else if (req.type === "video") { video.total += 1; if (scene.local_videos?.includes(`scene_${i}_vid_${vidIdx}.mp4`)) video.done += 1; vidIdx++; } } } return { audio, unsplash, ai, video }; } function AssetCounters({ scenes, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; const stats = computeAssetStats(scenes); if (!stats) return null; const items = [ { key: "audio", label: t.audios, ...stats.audio }, { key: "unsplash", label: t.searches, ...stats.unsplash }, { key: "ai", label: t.aiPhotos, ...stats.ai }, { key: "video", label: t.videos, ...stats.video }, ].filter(x => x.total > 0); if (items.length === 0) return null; return (
{items.map(({ key, label, done, total }) => { const complete = done === total; const inProgress = done > 0 && !complete; const dotColor = complete ? "var(--ok)" : inProgress ? "var(--accent)" : "var(--fg-4)"; const numColor = complete ? "var(--ok)" : inProgress ? "var(--accent)" : "var(--fg-2)"; return (
{label} {done} / {total}
); })}
); } function fmtTime(ms) { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const r = s % 60; const cs = Math.floor((ms % 1000) / 10); return `${String(m).padStart(2,"0")}:${String(r).padStart(2,"0")}.${String(cs).padStart(2,"0")}`; } // Map BE status string → index in STAGES (0..3) + completion flag const STATUS_MAP = { PENDING: { stageIdx: 0, done: false }, GENERATING_SCRIPT: { stageIdx: 0, done: false }, GENERATING_ASSETS: { stageIdx: 1, done: false }, AWAITING_REVIEW: { stageIdx: 1, done: true }, // assets stage finished, paused REGENERATING_MEDIA: { stageIdx: 1, done: true }, GENERATING_CODE: { stageIdx: 2, done: false }, RENDERING: { stageIdx: 3, done: false }, GENERATING_SUBTITLES: { stageIdx: 3, done: false }, COMPLETED: { stageIdx: 3, done: true }, FAILED: { stageIdx: 0, done: false, failed: true }, }; function PipelineScreen({ onComplete, onError, state, jobId, uiLang = "vi" }) { const startRef = React.useRef(performance.now()); const [now, setNow] = React.useState(0); const [job, setJob] = React.useState({ status: "PENDING", progress: 0, error: null, video_url: null }); const completedRef = React.useRef(false); const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; // wall-clock animation tick for log streams / preview animations React.useEffect(() => { let raf; const tick = () => { setNow(performance.now() - startRef.current); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); // poll backend status every 1.5s React.useEffect(() => { if (!jobId) return; let cancelled = false; const poll = async () => { try { const res = await fetch(`/api/status/${jobId}`); if (!res.ok) throw new Error(`status ${res.status}`); const data = await res.json(); if (cancelled) return; setJob(data); if (data.status === "COMPLETED" && !completedRef.current) { completedRef.current = true; const url = data.video_url || `/files/${jobId}/output.mp4`; setTimeout(() => onComplete && onComplete(url), 600); } else if (data.status === "FAILED") { onError && onError(data.error || "Job failed"); } } catch (e) { if (!cancelled) onError && onError(String(e.message || e)); } }; poll(); const id = setInterval(poll, 1500); return () => { cancelled = true; clearInterval(id); }; }, [jobId, onComplete, onError]); const mapped = STATUS_MAP[job.status] || STATUS_MAP.PENDING; const activeIdx = mapped.stageIdx; const stageDone = mapped.done; // compute per-stage progress: stages < activeIdx are done, == activeIdx is active, > activeIdx pending const stageInfo = STAGES.map((s, i) => { const isDone = i < activeIdx || (i === activeIdx && stageDone); const isActive = i === activeIdx && !stageDone; let p = 0; if (isDone) p = 1; else if (isActive) { // animate log lines based on wall-clock time spent in this stage; cap at 0.9 until next status arrives const elapsed = (now / 1000) % 12; p = Math.min(0.9, elapsed / 12 + (job.progress || 0) * 0.1); } const stageTrans = STAGE_TRANSLATIONS[uiLang]?.[s.id] || {}; return { ...s, name: stageTrans.name || s.name, sub: stageTrans.sub || s.sub, p, active: isActive, done: isDone }; }); const overallP = stageDone ? 1 : (activeIdx + (stageInfo[activeIdx]?.p || 0)) / STAGES.length; const totalDuration = STAGES.reduce((a, s) => a + s.duration, 0); const inReview = job.status === "AWAITING_REVIEW" || job.status === "REGENERATING_MEDIA" || job.status === "GENERATING_ASSETS"; return (
{/* main column */}
{job.status === "FAILED" && (
{t.failed}
{job.error || "Unknown error"}
)} {/* overall progress strip */}
{t.building} skillsvideo_voyager_1080p.mp4
{fmtTime(now)} / {fmtTime(totalDuration)} {Math.round(overallP * 100)}%
{stageInfo.slice(0, -1).map((s, i) => (
))}
{job.scenes && job.scenes.length > 0 && }
{/* stages OR review */} {(job.status === "AWAITING_REVIEW" || job.status === "REGENERATING_MEDIA" || job.status === "GENERATING_ASSETS") ? ( ) : (
{stageInfo.map((s, i) => )}
)}
{/* preview + meta (hidden during review for max real estate) */} {!inReview && (
)}
); } function StageCard({ stage, index, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; const logRef = React.useRef(null); const visibleLogs = stage.logs.slice(0, Math.floor(stage.p * stage.logs.length) + (stage.active ? 1 : 0)).filter(Boolean); React.useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [visibleLogs.length]); const status = stage.done ? t.doneStatus : stage.active ? t.activeStatus : t.pendingStatus; return (
{stage.done ? ( ) : stage.active ? ( ) : ( {index} )}
{stage.name} {stage.sub}
{stage.active && ( {Math.round(stage.p * 100)}% )} {status}
{/* progress strip */}
{/* log stream */} {(stage.active || stage.done) && visibleLogs.length > 0 && (
{visibleLogs.map((line, i) => (
{String(i).padStart(3, "0")} {line}{i === visibleLogs.length - 1 && stage.active && }
))}
)}
); } function Spinner() { return ( ); } function PreviewPane({ stageInfo, now, job, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; // pick the active stage's visual treatment const active = stageInfo.find(s => s.active) || stageInfo[stageInfo.length - 1]; return (
{t.previewTitle} {t.livePreview.replace("{stage}", active?.name?.toLowerCase())}
{/* scene mockup that morphs by stage */} {active?.id === "analyze" && } {active?.id === "visual" && } {active?.id === "post" && } {(active?.id === "render" || stageInfo[3].done) && } {/* scan line */}
); } function AnalyzeViz({ now, job, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; const scenes = job?.scenes || []; const beats = scenes.length > 0 ? scenes.map(s => s.caption || s.visual_description || (uiLang === "vi" ? "Phân cảnh..." : "Scene...")) : (uiLang === "vi" ? ["Năm 1977.", "NASA phóng tàu.", "Voyager 1 & 2.", "Đĩa ghi vàng.", "Thư trong chai.", "47 năm sau.", "Vẫn đang truyền tín hiệu."] : ["Year 1977.", "NASA launches.", "Voyager 1 & 2.", "Golden record.", "Bottle messages.", "Forty-seven years later.", "Still transmitting."]); return (
{beats.map((b, i) => (
200 + i * 350 ? 1 : 0.15, transition: "opacity .25s", }}> {t.beatsLabel.replace("{num}", String(i + 1).padStart(2, "0"))} 200 + i * 350 ? "var(--fg-0)" : "var(--fg-3)" }}>{b} {now > 400 + i * 350 && }
))}
); } function VisualViz({ now, job, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; // grid of "scene thumbnails" filling in const scenes = job?.scenes || Array.from({ length: 11 }); return (
{scenes.map((s, i) => { const appearAt = 4200 + i * 240; // stage starts at 4200ms const visible = now > appearAt || job?.status !== "GENERATING_ASSETS"; const hueShift = (i * 31) % 360; let imageUrl = null; if (s?.local_images?.length > 0) { imageUrl = `/files/${job.job_id}/public/${s.local_images[0]}`; } else if (s?.local_image) { imageUrl = `/files/${job.job_id}/public/${s.local_image}`; } let videoUrl = null; if (s?.local_videos?.length > 0) { videoUrl = `/files/${job.job_id}/public/${s.local_videos[0]}`; } return (
{imageUrl && } {videoUrl &&
); } function PostViz({ now, job, uiLang = "vi" }) { // waveform + subtitle overlay simulation const bars = 80; return (
{/* subtitle preview */}
{uiLang === "vi" ? ( <>chúng vẫn đang truyền tín hiệu ) : ( <>they are still transmitting )}
{/* waveform */}
{Array.from({ length: bars }).map((_, i) => { const phase = (i / bars) * Math.PI * 4 + now * 0.003; const h = (Math.sin(phase) * 0.4 + Math.sin(phase * 1.7) * 0.3 + 0.5) * 50 + 4; const isActive = i < (now / 12000) * bars; return (
); })}
); } function RenderViz({ now, done, job, uiLang = "vi" }) { return (
{uiLang === "vi" ? ( <>Tàu Voyager 1 và Voyager 2 vẫn đang truyền tín hiệu. ) : ( <>Voyager 1 and Voyager 2 are still transmitting. )}
{done && (
{uiLang === "vi" ? "SẴN SÀNG" : "READY"}
)}
); } function MetaPanel({ state, jobId, jobStatus, uiLang = "vi" }) { const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi; const wordCount = state.source.trim() ? state.source.trim().split(/\s+/).length : 320; const stylePreset = STYLE_PRESETS.find(s => s.id === state.settings.style) || {}; const styleName = translateStylePreset(stylePreset, uiLang).name || state.settings.style; return (
{t.jobTitle}
{[ [t.jobId, jobId ? jobId.slice(0, 8) : "—"], [t.status, jobStatus || "—"], [t.style, styleName], [t.language, LANGUAGES.find(l => l.code === state.settings.language)?.label || state.settings.language], [t.aspect, state.settings.aspect], [t.words, wordCount], ].map(([k, v]) => (
{k}
{v}
))}
); } // ───────────────────────────────────────────────────────────────────────── // MediaPreviewModal — lightbox for full-size image / playable video preview. // ───────────────────────────────────────────────────────────────────────── function MediaPreviewModal({ media, onClose, uiLang }) { React.useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); if (!media) return null; const { url, type, title, query } = media; const isVideo = type === "video"; return (
e.stopPropagation()} style={{ maxWidth: "92vw", maxHeight: "92vh", display: "flex", flexDirection: "column", gap: 12, alignItems: "center", cursor: "default", }}> {isVideo ? (
); } // ───────────────────────────────────────────────────────────────────────── // ReviewScreen — shown when status === AWAITING_REVIEW. Lets user inspect // every scene, edit visual_description and media queries, regenerate any // single media item, then click Continue to start the code+render phase. // Caption (voiceover) is locked because TTS audio was already generated. // ───────────────────────────────────────────────────────────────────────── function ReviewScreen({ job, jobId, onJobUpdate, isRegenerating, uiLang }) { const [preview, setPreview] = React.useState(null); // {url, type, title, query} const scenes = job.scenes || []; // Local edit buffer keyed by scene index. Holds pending visual_description + media query strings. const [edits, setEdits] = React.useState({}); const [savingScene, setSavingScene] = React.useState(null); const [regenKey, setRegenKey] = React.useState(null); // `${sceneIdx}:${mediaIdx}` const [bulkRegenBusy, setBulkRegenBusy] = React.useState(false); const [continueLoading, setContinueLoading] = React.useState(false); // Bust image/video URL cache after regenerate const [bustToken, setBustToken] = React.useState({}); // Which scene indices are expanded. Default: all collapsed when many scenes, otherwise all expanded. const initialExpanded = React.useMemo( () => new Set(scenes.length <= 6 ? scenes.map((_, i) => i) : []), // run once on mount [], // eslint-disable-line react-hooks/exhaustive-deps ); const [expanded, setExpanded] = React.useState(initialExpanded); // Filters const [filterText, setFilterText] = React.useState(""); const [onlyUnsaved, setOnlyUnsaved] = React.useState(false); const [onlyType, setOnlyType] = React.useState("all"); // all|ai|video|unsplash const [activeScene, setActiveScene] = React.useState(0); // Per-scene refs so the rail can scroll-into-view const sceneRefs = React.useRef({}); const scrollContainerRef = React.useRef(null); const getEdit = (i, field, fallback) => { const e = edits[i]; if (e && field in e) return e[field]; return fallback; }; const setEdit = (i, field, value) => { setEdits((prev) => ({ ...prev, [i]: { ...(prev[i] || {}), [field]: value } })); }; const setMediaQueryEdit = (i, m, value) => { setEdits((prev) => { const cur = prev[i] || {}; const queries = { ...(cur.queries || {}) }; queries[m] = value; return { ...prev, [i]: { ...cur, queries } }; }); }; const getMediaQuery = (i, m, fallback) => { const e = edits[i]; if (e && e.queries && m in e.queries) return e.queries[m]; return fallback; }; const sceneDirty = (i) => { const scene = scenes[i]; const e = edits[i]; if (!e) return false; if ("visual_description" in e && e.visual_description !== (scene.visual_description || "")) return true; if (e.queries) { const orig = scene.media_requests || []; for (const m of Object.keys(e.queries)) { if (e.queries[m] !== (orig[m]?.query || "")) return true; } } return false; }; const saveScene = async (i) => { const scene = scenes[i]; setSavingScene(i); try { const e = edits[i] || {}; const body = {}; if ("visual_description" in e) body.visual_description = e.visual_description; if (e.queries) { body.media_requests = (scene.media_requests || []).map((req, m) => ({ type: req.type, query: m in e.queries ? e.queries[m] : req.query, })); } const res = await fetch(`/api/jobs/${jobId}/scenes/${i}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`save failed: ${res.status}`); // Reflect the change locally so other UI stays in sync until next poll const data = await res.json(); onJobUpdate((j) => { const next = [...(j.scenes || [])]; next[i] = data.scene; return { ...j, scenes: next }; }); // Clear edit buffer for this scene setEdits((prev) => { const n = { ...prev }; delete n[i]; return n; }); } catch (err) { alert((uiLang === "en" ? "Save failed: " : "Lưu thất bại: ") + (err.message || err)); } finally { setSavingScene(null); } }; const regenerateMedia = async (i, m) => { const key = `${i}:${m}`; setRegenKey(key); try { // Auto-save the new query first (if dirty for this media) const e = edits[i]; if (e && e.queries && m in e.queries) { await saveScene(i); } const res = await fetch(`/api/jobs/${jobId}/scenes/${i}/media/${m}/regenerate`, { method: "POST" }); if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(detail.detail || `regen failed: ${res.status}`); } const data = await res.json(); onJobUpdate((j) => { const next = [...(j.scenes || [])]; next[i] = data.scene; return { ...j, scenes: next }; }); setBustToken((prev) => ({ ...prev, [data.filename]: Date.now() })); } catch (err) { alert((uiLang === "en" ? "Regenerate failed: " : "Tạo lại thất bại: ") + (err.message || err)); } finally { setRegenKey(null); } }; // Count missing image/video assets (file-presence based — same logic as // header counters). Audio isn't regenerable through this endpoint, so we // don't include it here. const failedCount = React.useMemo(() => { let n = 0; for (let i = 0; i < scenes.length; i++) { const s = scenes[i]; let imgIdx = 0; let vidIdx = 0; for (const req of (s?.media_requests || [])) { if (req.type === "unsplash" || req.type === "ai" || req.type === "gemini" || req.type === "local") { if (!s.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) n++; imgIdx++; } else if (req.type === "video") { if (!s.local_videos?.includes(`scene_${i}_vid_${vidIdx}.mp4`)) n++; vidIdx++; } } } return n; }, [scenes]); const regenerateAllFailed = async () => { if (bulkRegenBusy || failedCount === 0) return; setBulkRegenBusy(true); try { const res = await fetch(`/api/jobs/${jobId}/media/regenerate-failed`, { method: "POST" }); if (!res.ok) { const d = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(d.detail || `bulk regen failed: ${res.status}`); } const data = await res.json(); onJobUpdate((j) => ({ ...j, scenes: data.scenes })); const ts = Date.now(); const bust = {}; for (const it of data.items || []) { if (it.status === "ok" && it.filename) bust[it.filename] = ts; } if (Object.keys(bust).length > 0) { setBustToken((prev) => ({ ...prev, ...bust })); } alert(uiLang === "en" ? `Regenerated ${data.succeeded}/${data.total} OK${data.failed ? `, ${data.failed} failed` : ""}` : `Đã tạo lại ${data.succeeded}/${data.total} thành công${data.failed ? `, ${data.failed} thất bại` : ""}`); } catch (err) { alert((uiLang === "en" ? "Bulk regenerate failed: " : "Tạo lại tất cả thất bại: ") + (err.message || err)); } finally { setBulkRegenBusy(false); } }; const toggleExpand = (i) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(i)) next.delete(i); else next.add(i); return next; }); }; const expandAll = () => setExpanded(new Set(scenes.map((_, i) => i))); const collapseAll = () => setExpanded(new Set()); const sceneMatchesFilter = (scene, i) => { if (onlyUnsaved && !sceneDirty(i)) return false; if (onlyType !== "all") { const types = (scene.media_requests || []).map(r => r.type); if (onlyType === "ai" && !types.some(t => t === "ai" || t === "gemini")) return false; if (onlyType === "video" && !types.includes("video")) return false; if (onlyType === "unsplash" && !types.includes("unsplash")) return false; } if (filterText.trim()) { const q = filterText.trim().toLowerCase(); const hay = `${scene.caption || ""} ${scene.visual_description || ""} ${(scene.media_requests || []).map(r => r.query).join(" ")}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; }; const visibleIndices = scenes.map((_, i) => i).filter((i) => sceneMatchesFilter(scenes[i], i)); const jumpTo = (i) => { setExpanded((prev) => new Set(prev).add(i)); const el = sceneRefs.current[i]; if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); }; // Scroll-spy: track which scene is most visible in the scroll container React.useEffect(() => { const container = scrollContainerRef.current; if (!container) return; const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => b.intersectionRatio - a.intersectionRatio); if (visible.length > 0) { const idx = Number(visible[0].target.dataset.sceneIdx); if (!Number.isNaN(idx)) setActiveScene(idx); } }, { root: container, rootMargin: "-30% 0px -50% 0px", threshold: [0, 0.25, 0.5, 1] }, ); Object.values(sceneRefs.current).forEach((el) => el && observer.observe(el)); return () => observer.disconnect(); }, [scenes.length, visibleIndices.length]); const dirtyCount = scenes.filter((_, i) => sceneDirty(i)).length; const continueJob = async () => { // Block continue if anything is unsaved const dirtyIdx = scenes.map((_, i) => i).filter(sceneDirty); if (dirtyIdx.length > 0) { if (!confirm(uiLang === "en" ? `There are ${dirtyIdx.length} unsaved scenes. Continuing will discard these edits. OK?` : `Có ${dirtyIdx.length} phân cảnh chưa lưu. Tiếp tục sẽ bỏ qua thay đổi đó. OK?`)) return; } setContinueLoading(true); try { const res = await fetch(`/api/jobs/${jobId}/continue`, { method: "POST" }); if (!res.ok) throw new Error(`continue failed: ${res.status}`); } catch (err) { alert((uiLang === "en" ? "Failed to continue: " : "Không tiếp tục được: ") + (err.message || err)); setContinueLoading(false); } }; return (
{job.status === "GENERATING_ASSETS" ? (uiLang === "en" ? "Generating assets" : "Đang tải tài nguyên") : (uiLang === "en" ? "Awaiting review" : "Chờ duyệt")} · {scenes.length} {uiLang === "en" ? "scenes" : "phân cảnh"} {dirtyCount > 0 && · {dirtyCount} {uiLang === "en" ? "unsaved" : "chưa lưu"}}
{uiLang === "en" ? "Review and edit media. Voiceover has been generated so caption cannot be changed." : "Kiểm tra và chỉnh sửa phương tiện. Lời thoại đã được tạo nên không thể thay đổi."}
{/* Show a subtle note when audio is ready but images/video still loading */} {job.status === "GENERATING_ASSETS" && job.audio_ready && (
{uiLang === "en" ? "Images/videos are still loading — you can continue or wait" : "Ảnh/video vẫn đang tải — có thể tiếp tục hoặc chờ thêm"}
)}
{(() => { // Disabled only when audio hasn't finished yet const waitingForAudio = job.status === "GENERATING_ASSETS" && !job.audio_ready; const isDisabled = continueLoading || isRegenerating || waitingForAudio; let label = uiLang === "en" ? "Continue to render →" : "Tiếp tục xuất video →"; if (continueLoading) { label = uiLang === "en" ? "Continuing…" : "Đang tiếp tục…"; } else if (waitingForAudio) { label = uiLang === "en" ? "Generating audio…" : "Đang tạo lời thoại…"; } else if (job.status === "GENERATING_ASSETS" && job.audio_ready) { label = uiLang === "en" ? "Continue to render →" : "Tiếp tục xuất video →"; } return ( ); })()}
{/* Filter bar */}
setFilterText(e.target.value)} placeholder={uiLang === "en" ? "Search caption, visual, or query…" : "Tìm kiếm lời thoại, hình ảnh hoặc từ khóa..."} style={{ flex: "1 1 240px", minWidth: 200, padding: "8px 12px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--radius)", fontSize: 13, }} /> setOnlyUnsaved((v) => !v)}> {uiLang === "en" ? "Only unsaved" : "Chưa lưu"} setOnlyType((v) => v === "ai" ? "all" : "ai")}> {uiLang === "en" ? "AI image" : "Ảnh AI"} setOnlyType((v) => v === "unsplash" ? "all" : "unsplash")}> {uiLang === "en" ? "Unsplash" : "Unsplash"} setOnlyType((v) => v === "video" ? "all" : "video")}> {uiLang === "en" ? "Video" : "Video"}
{visibleIndices.length}/{scenes.length} {uiLang === "en" ? "shown" : "đang hiển thị"}
{/* Body: rail (left) + scrollable scene column (right) */}
{/* RAIL */} {/* MAIN scenes column */}
{visibleIndices.length === 0 ? (
{uiLang === "en" ? "No scenes match the current filter." : "Không có phân cảnh nào khớp với bộ lọc hiện tại."}
) : visibleIndices.map((i) => { const scene = scenes[i]; return (
{ sceneRefs.current[i] = el; }} data-scene-idx={i}> setEdit(i, "visual_description", v)} getMediaQuery={(m) => getMediaQuery(i, m, scene.media_requests?.[m]?.query || "")} onMediaQueryChange={(m, v) => setMediaQueryEdit(i, m, v)} onSave={() => saveScene(i)} onRegenerateMedia={(m) => regenerateMedia(i, m)} onPreviewMedia={setPreview} isSaving={savingScene === i} regenKey={regenKey} isDirty={sceneDirty(i)} bustToken={bustToken} isExpanded={expanded.has(i)} onToggleExpand={() => toggleExpand(i)} uiLang={uiLang} />
); })}
{preview && setPreview(null)} uiLang={uiLang}/>}
); } function FilterChip({ active, onClick, children }) { return ( ); } function chipBtnStyle(active) { return { padding: "6px 10px", background: active ? "var(--accent-soft)" : "var(--bg-2)", border: `1px solid ${active ? "var(--accent-line)" : "var(--line)"}`, borderRadius: "var(--radius)", fontSize: 12, color: active ? "var(--accent)" : "var(--fg-2)", cursor: "pointer", whiteSpace: "nowrap", }; } // Chip showing the template Agent 1 picked for a scene. Hover → animated preview. // Reuses TemplatePreview / TEMPLATE_TRANSLATIONS defined globally in input-screen.jsx. function ScenePickedTemplate({ templateId, uiLang }) { const [hover, setHover] = React.useState(false); if (!templateId) { return ( {uiLang === "en" ? "Custom" : "Tùy chỉnh"} ); } const tr = (typeof TEMPLATE_TRANSLATIONS !== "undefined" && TEMPLATE_TRANSLATIONS[uiLang] && TEMPLATE_TRANSLATIONS[uiLang][templateId]) || null; const name = tr ? tr.name : templateId; const canPreview = typeof TemplatePreview !== "undefined"; return ( e.stopPropagation()} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{ position: "relative", flexShrink: 0, fontSize: 10.5, padding: "2px 8px", borderRadius: 4, background: "var(--accent-soft)", border: "1px solid var(--accent-line)", color: "var(--accent)", fontFamily: "var(--font-mono)", display: "inline-flex", alignItems: "center", gap: 4, cursor: canPreview ? "help" : "default", }}> ▦ {name} {hover && canPreview && (
)}
); } function SceneReviewCard({ sceneIdx, scene, jobId, visualDescription, onVisualChange, getMediaQuery, onMediaQueryChange, onSave, onRegenerateMedia, onPreviewMedia, isSaving, regenKey, isDirty, bustToken, isExpanded, onToggleExpand, uiLang, }) { const mediaList = scene.media_requests || []; const caption = scene.caption || ""; const duration = scene.duration_seconds; const hasAudio = !!scene.local_audio; const mediaFilename = (m) => { const req = mediaList[m]; if (!req) return null; if (req.type === "video") { const videoPos = mediaList.slice(0, m).filter(r => r.type === "video").length; const expectedName = `scene_${sceneIdx}_vid_${videoPos}.mp4`; return scene.local_videos?.includes(expectedName) ? expectedName : null; } const imagePos = mediaList.slice(0, m).filter(r => ["unsplash", "ai", "gemini"].includes(r.type)).length; const expectedName = `scene_${sceneIdx}_img_${imagePos}.jpg`; return scene.local_images?.includes(expectedName) ? expectedName : null; }; return (
{/* Header row — click to toggle expand */}
{uiLang === "en" ? "Scene" : "Phân cảnh"} {String(sceneIdx + 1).padStart(2, "0")} {duration != null && ( {duration.toFixed(2)}s )} {hasAudio && ( )} {!isExpanded && ( {caption.slice(0, 120) || {uiLang === "en" ? "(no caption)" : "(không có lời thoại)"}} )} {!isExpanded && mediaList.length > 0 && ( {mediaList.length} {uiLang === "en" ? "media" : "phương tiện"} )}
{isExpanded && (<> {/* Caption (read-only — TTS already generated) */}
{caption || {uiLang === "en" ? "(no caption)" : "(không có lời thoại)"}}
{hasAudio && (
♪ TTS
)}
{/* Visual description */}