// 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 (
{/* 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 &&
}
{visible && !imageUrl && !videoUrl && (
{t.sceneNumLabel.replace("{num}", String(i + 1).padStart(2, "0"))}
)}
{!visible && !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]) => (
))}
);
}
// ─────────────────────────────────────────────────────────────────────────
// 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 ? (
) : (
)}
{title} · {type}
{query &&
{query}
}
{uiLang === "en" ? "Close (Esc)" : "Đóng (Esc)"}
);
}
// ─────────────────────────────────────────────────────────────────────────
// 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 (
{label}
);
})()}
{/* 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ị"}
{uiLang === "en" ? "Expand all" : "Mở rộng tất cả"}
{uiLang === "en" ? "Collapse all" : "Thu gọn tất cả"}
0),
opacity: (bulkRegenBusy || failedCount === 0) ? 0.55 : 1,
cursor: (bulkRegenBusy || failedCount === 0) ? "not-allowed" : "pointer",
}}
title={failedCount === 0
? (uiLang === "en" ? "No failed assets" : "Không có tài nguyên lỗi")
: (uiLang === "en" ? `Regen ${failedCount} scene(s) with failed media` : `Tạo lại ${failedCount} phân cảnh lỗi`)}>
{bulkRegenBusy
? (uiLang === "en" ? "Regenerating…" : "Đang tạo lại...")
: `↻ ${uiLang === "en" ? "Regen failed" : "Tạo lại lỗi"} (${failedCount})`}
{/* Body: rail (left) + scrollable scene column (right) */}
{/* RAIL */}
{uiLang === "en" ? "Timeline" : "Dòng thời gian"}
{visibleIndices.map((i) => {
const scene = scenes[i];
const dirty = sceneDirty(i);
const isActive = activeScene === i;
// first media thumbnail (if any)
const firstReq = (scene.media_requests || [])[0];
let thumbUrl = null;
if (firstReq) {
const fname = firstReq.type === "video"
? `scene_${i}_vid_0.mp4`
: `scene_${i}_img_0.jpg`;
const isReady = firstReq.type === "video"
? scene.local_videos?.includes(fname)
: scene.local_images?.includes(fname);
if (isReady) {
const bust = bustToken[fname] || "";
thumbUrl = `/files/${jobId}/public/${fname}${bust ? `?t=${bust}` : ""}`;
}
}
const snippet = (scene.caption || scene.visual_description || "").slice(0, 48);
return (
jumpTo(i)}
style={{
display: "grid",
gridTemplateColumns: "auto 56px 1fr auto",
gap: 8,
alignItems: "center",
width: "100%",
padding: "6px 8px",
marginBottom: 2,
background: isActive ? "var(--bg-3)" : "transparent",
border: `1px solid ${isActive ? "var(--accent-line)" : "transparent"}`,
borderRadius: "var(--radius)",
cursor: "pointer",
textAlign: "left",
transition: "background .1s",
}}>
{String(i + 1).padStart(2, "0")}
{thumbUrl && firstReq?.type !== "video" && (
)}
{thumbUrl && firstReq?.type === "video" && (
)}
{/* Placeholder when no media ready yet */}
{!thumbUrl && (() => {
const hasVideoReq = (scene.media_requests || []).some(r => r.type === "video");
const hasAnyReq = (scene.media_requests || []).length > 0;
return (
{hasVideoReq ? (
) : hasAnyReq ? (
) : (
)}
{hasVideoReq ? "video" : hasAnyReq ? "image" : "text"}
);
})()}
{/* FAIL badge — shown when backend flagged media_failed */}
{scene.media_failed && (
FAIL
)}
{snippet || "—"}
{dirty && }
);
})}
{visibleIndices.length === 0 && (
{uiLang === "en" ? "No scenes match filter." : "Không có phân cảnh nào khớp."}
)}
{/* 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 (
{children}
);
}
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"}
)}
{ e.stopPropagation(); onSave(); }}
disabled={!isDirty || isSaving}
style={{
padding: "6px 14px",
background: isDirty ? "var(--bg-3)" : "transparent",
border: `1px solid ${isDirty ? "var(--accent-line)" : "var(--line)"}`,
borderRadius: "var(--radius)",
fontSize: 12.5,
fontWeight: 500,
color: isDirty ? "var(--accent)" : "var(--fg-3)",
cursor: (isDirty && !isSaving) ? "pointer" : "default",
flexShrink: 0,
}}>
{isSaving
? (uiLang === "en" ? "Saving…" : "Đang lưu...")
: isDirty
? (uiLang === "en" ? "Save" : "Lưu")
: (uiLang === "en" ? "Saved" : "Đã lưu")}
{isExpanded && (<>
{/* Caption (read-only — TTS already generated) */}
{uiLang === "en" ? "Caption (voiceover, locked)" : "Lời thoại (Thuyết minh, đã khóa)"}
🔒
{caption || {uiLang === "en" ? "(no caption)" : "(không có lời thoại)"} }
{hasAudio && (
)}
{/* Visual description */}
{uiLang === "en" ? "Visual description" : "Mô tả hình ảnh"}
{/* Media grid */}
{mediaList.length > 0 && (
{uiLang === "en"
? `Media (${mediaList.length}) — click to preview`
: `Phương tiện (${mediaList.length}) — nhấp để xem trước`}
{mediaList.map((req, m) => {
const fname = mediaFilename(m);
const bust = bustToken[fname] || "";
const url = fname ? `/files/${jobId}/public/${fname}${bust ? `?t=${bust}` : ""}` : null;
const isVideo = req.type === "video";
const key = `${sceneIdx}:${m}`;
const regenerating = regenKey === key;
const previewObj = url ? {
url, type: req.type, title: `Scene ${sceneIdx + 1} · #${m + 1}`, query: getMediaQuery(m),
} : null;
return (
{/* big thumb — click to open lightbox */}
previewObj && onPreviewMedia(previewObj)}
disabled={!previewObj}
style={{
position: "relative",
width: "100%",
aspectRatio: "16 / 9",
background: "var(--bg-3)",
borderRadius: 6,
overflow: "hidden",
cursor: previewObj ? "zoom-in" : "default",
padding: 0,
border: "1px solid var(--line)",
}}>
{url && !isVideo && (
)}
{url && isVideo && (
)}
{isVideo && (
VIDEO
)}
{regenerating && (
{uiLang === "en" ? "Regenerating…" : "Đang tạo lại..."}
)}
{/* meta */}
#{m + 1} · {req.type}
{/* query input */}
onMediaQueryChange(m, e.target.value)}
disabled={regenerating}
placeholder={uiLang === "en" ? "Search query…" : "Từ khóa tìm kiếm..."}
style={{
padding: "8px 10px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: 4,
fontSize: 13,
width: "100%",
}}
/>
{/* regenerate */}
onRegenerateMedia(m)}
disabled={regenerating || isSaving}
style={{
padding: "8px 12px",
background: regenerating ? "var(--bg-2)" : "var(--bg-3)",
border: "1px solid var(--accent-line)",
borderRadius: "var(--radius)",
fontSize: 12.5,
fontWeight: 500,
color: regenerating ? "var(--fg-3)" : "var(--accent)",
cursor: regenerating ? "wait" : "pointer",
whiteSpace: "nowrap",
}}>
{regenerating
? (uiLang === "en" ? "Regenerating…" : "Đang tạo lại...")
: `↻ ${uiLang === "en" ? "Regenerate" : "Tạo lại"}`}
);
})}
)}
>)}
);
}
Object.assign(window, { PipelineScreen, STAGES, ReviewScreen });