// output-screen.jsx — final video preview, timeline, downloads.
const SCENES = [
{ id: 1, label: "Cold open · Cape Canaveral", duration: 5.2, color: "oklch(0.45 0.12 60)" },
{ id: 2, label: "Voyager assembly", duration: 7.4, color: "oklch(0.38 0.08 80)" },
{ id: 3, label: "Golden record macro", duration: 6.0, color: "oklch(0.52 0.10 50)" },
{ id: 4, label: "Launch sequence", duration: 8.1, color: "oklch(0.40 0.14 30)" },
{ id: 5, label: "Deep space transit", duration: 9.6, color: "oklch(0.28 0.08 280)" },
{ id: 6, label: "Heliopause graphic", duration: 5.4, color: "oklch(0.45 0.10 240)" },
{ id: 7, label: "Modern radio dish", duration: 6.8, color: "oklch(0.32 0.06 200)" },
{ id: 8, label: "Voyager — still pinging", duration: 7.0, color: "oklch(0.50 0.14 70)" },
{ id: 9, label: "Tag — open question", duration: 4.5, color: "oklch(0.22 0.04 60)" },
];
const TOTAL = SCENES.reduce((a, s) => a + s.duration, 0);
function fmtSec(s) {
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${String(r).padStart(2,"0")}`;
}
function OutputScreen({ state, job, onRestart, onUpdateJob, uiLang = "vi" }) {
const videoUrl = job?.videoUrl || null;
return (
);
}
function VideoPlayer({ videoUrl, uiLang }) {
return (
{videoUrl ? (
) : (
{uiLang === "en" ? "Video not ready" : "Video chưa sẵn sàng"}
)}
);
}
function Player({ t, setT, playing, setPlaying, scene, sceneStart, sub }) {
// dynamic caption — pick a phrase based on scene
const CAPTIONS = {
1: "The year is nineteen seventy-seven.",
2: "NASA assembles two spacecraft.",
3: "Each carries a golden record.",
4: "Cape Canaveral. Launch.",
5: "Voyager 1 and Voyager 2.",
6: "They are bottle messages.",
7: "Hurled into the dark.",
8: "Forty-seven years later, still transmitting.",
9: "What did they hear back?",
};
const caption = CAPTIONS[scene.id] || "";
const words = caption.split(" ");
const local = t - sceneStart;
const wordIdx = Math.min(words.length - 1, Math.floor((local / scene.duration) * words.length));
return (
{/* viewer */}
{/* subtitle */}
{words.map((w, i) => (
{w}
))}
{/* watermark / safe area markers */}
{[[0,0,1,1],[1,0,-1,1],[0,1,1,-1],[1,1,-1,-1]].map(([x,y,dx,dy], i) => (
))}
{/* transport */}
setT(Math.max(0, t - 5))}
style={{ color: "var(--fg-1)", padding: 6 }} title="Back 5s">
setPlaying(!playing)}
style={{
width: 38, height: 38, borderRadius: 999,
background: "var(--accent)", color: "var(--accent-fg)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{playing
?
: }
setT(Math.min(TOTAL, t + 5))}
style={{ color: "var(--fg-1)", padding: 6 }} title="Forward 5s">
{fmtSec(t)} / {fmtSec(TOTAL)}
{/* scrubber */}
{
const rect = e.currentTarget.getBoundingClientRect();
const p = (e.clientX - rect.left) / rect.width;
setT(p * TOTAL);
}}
>
1×
·
{state_resolution_label()}
);
}
function state_resolution_label() { return "1080p"; }
function SceneCanvas({ scene, t, localT }) {
// procedurally drawn "scene" using gradients + simple shapes.
// ken-burns zoom on the layer
const zoom = 1 + (localT / scene.duration) * 0.08;
return (
{/* scene-specific accents */}
{(scene.id === 1 || scene.id === 4) && (
// launch — vertical light pillar
)}
{(scene.id === 2 || scene.id === 3 || scene.id === 6) && (
// grid / schematic
)}
{(scene.id === 5 || scene.id === 8) && (
// deep space — stars
{Array.from({ length: 80 }).map((_, i) => {
const x = ((i * 37) % 100);
const y = ((i * 71) % 100);
const r = (i % 3 === 0) ? 1.2 : 0.5;
return ;
})}
{scene.id === 8 && (
)}
)}
{scene.id === 7 && (
// radio dish silhouette
)}
{/* film grain */}
);
}
function Timeline({ t, setT, scenes }) {
return (
{/* time ruler */}
{Array.from({ length: Math.ceil(TOTAL / 5) + 1 }).map((_, i) => {
const x = (i * 5 / TOTAL) * 100;
return (
{fmtSec(i * 5)}
);
})}
{/* playhead in ruler */}
{/* video track */}
{(s, x, w) => (
{String(s.id).padStart(2, "0")}
{s.label}
)}
{/* audio track */}
{(s, x, w) => (
{Array.from({ length: Math.floor(w * 1.6) }).map((_, i) => {
const h = (Math.sin(i * 0.6 + s.id) * 0.3 + Math.sin(i * 1.3) * 0.2 + 0.5) * 80 + 10;
return
;
})}
)}
{/* subtitle track */}
{(s, x, w) => (
CC · {s.id}
)}
);
}
function Track({ label, scenes, children, compact, last, onSeek }) {
return (
{label}
{
const r = e.currentTarget.getBoundingClientRect();
onSeek(((e.clientX - r.left) / r.width) * TOTAL);
}}
>
{(() => {
let acc = 0;
return scenes.map((s) => {
const x = (acc / TOTAL) * 100;
const w = (s.duration / TOTAL) * 100;
acc += s.duration;
return {children(s, x, w)} ;
});
})()}
);
}
function SummaryCard({ state, job, uiLang }) {
const jobId = job?.id;
const stylePreset = STYLE_PRESETS.find(s => s.id === state.settings.style) || {};
const styleName = translateStylePreset(stylePreset, uiLang).name || state.settings.style;
const langLabel = LANGUAGES.find(l => l.code === state.settings.language)?.label || state.settings.language;
const subtitlesVal = state.settings.enableSubtitles !== false
? (uiLang === "en" ? "On" : "Bật")
: (uiLang === "en" ? "Off" : "Tắt");
const fields = uiLang === "en" ? [
["Style", styleName],
["Language", langLabel],
["Aspect", state.settings.aspect],
["Subtitles", subtitlesVal],
] : [
["Phong cách", styleName],
["Ngôn ngữ", langLabel === "Vietnamese" ? "Tiếng Việt" : langLabel === "English" ? "Tiếng Anh" : langLabel],
["Tỷ lệ", state.settings.aspect],
["Phụ đề", subtitlesVal],
];
return (
{uiLang === "en" ? "Ready · " : "Sẵn sàng · "}● {uiLang === "en" ? "delivered" : "đã xuất"}
output.mp4
Job {jobId ? jobId.slice(0, 8) : "—"}
{fields.map(([k, v]) => (
))}
);
}
function DownloadsCard({ state, job, uiLang }) {
const videoUrl = job?.videoUrl || null;
// Khi user bấm tải, gọi endpoint download-and-purge: BE stream file rồi
// tự xóa workspace + outputs ngay sau khi response gửi xong.
const downloadUrl = job?.id ? `/api/jobs/${job.id}/download` : videoUrl;
const files = videoUrl ? [
{ name: uiLang === "en" ? "Video output" : "Video kết quả", size: "MP4", note: "primary", ext: "mp4", url: downloadUrl },
] : [];
return (
{uiLang === "en" ? "Downloads" : "Tải xuống"}
);
}
function ActionsCard({ onRestart, uiLang }) {
const actions = uiLang === "en" ? [
{ l: "Push to YouTube", note: "Connect channel", icon: "yt" },
{ l: "Open in editor", note: "Fine-tune scenes", icon: "edit" },
{ l: "Re-render with tweaks", note: "Settings, voice, style", icon: "redo" },
] : [
{ l: "Đăng lên YouTube", note: "Kết nối kênh", icon: "yt" },
{ l: "Mở trong trình dựng", note: "Chỉnh sửa phân cảnh chi tiết", icon: "edit" },
{ l: "Xuất lại với tinh chỉnh", note: "Cấu hình, giọng nói, kiểu dáng", icon: "redo" },
];
return (
{actions.map((a) => (
e.currentTarget.style.background = "var(--bg-3)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
{a.icon === "yt" && }
{a.icon === "edit" && }
{a.icon === "redo" && }
))}
e.currentTarget.style.background = "var(--bg-3)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
{uiLang === "en" ? "← Start a new project" : "← Bắt đầu dự án mới"}
);
}
function SubtitleStyleCard({ state, job, onUpdateJob, uiLang }) {
// 'done' if subtitles were burned at render time; otherwise 'idle' → user
// must opt-in via the CTA below before the styling controls are available.
const initialPhase = state?.settings?.enableSubtitles ? "done" : "idle";
const [phase, setPhase] = React.useState(initialPhase);
const [addError, setAddError] = React.useState(null);
const [sub, setSub] = React.useState({
font: "Arial",
size: 36,
maxWords: 6,
position: "bottom",
marginV: 120,
color_text: "&H00FFFFFF",
color_outline: "&H00000000",
color_back: "&H90000000",
color_highlight: "&H0000FFFF",
mode: "capcut", // "capcut" (per-word pill) | "plain" (sentence-only)
});
const [loading, setLoading] = React.useState(false);
const SUBTITLE_MODES = [
{ id: "capcut", label: "CapCut" },
{ id: "plain", label: "Plain" },
];
const startAddSubtitles = async () => {
if (!job?.id) return;
setAddError(null);
setPhase("adding");
try {
const res = await fetch(`/api/jobs/${job.id}/subtitles/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subtitle_mode: sub.mode }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`API ${res.status}: ${errText}`);
}
// Poll status until COMPLETED or FAILED (subtitle pipeline runs in background).
const maxAttempts = 180; // ~6 min @ 2s
for (let i = 0; i < maxAttempts; i++) {
await new Promise(r => setTimeout(r, 2000));
const r = await fetch(`/api/status/${job.id}`);
if (!r.ok) throw new Error(`Status ${r.status}`);
const data = await r.json();
if (data.status === "COMPLETED") {
if (data.video_url && onUpdateJob) {
onUpdateJob({ ...job, videoUrl: data.video_url });
}
setPhase("done");
return;
}
if (data.status === "FAILED") {
throw new Error(data.error || "Subtitle generation failed");
}
}
throw new Error("Timed out waiting for subtitles");
} catch (e) {
setAddError(e.message || String(e));
setPhase("idle");
}
};
const toAssColor = (hex, alpha="00") => {
const r = hex.slice(1, 3);
const g = hex.slice(3, 5);
const b = hex.slice(5, 7);
return `&H${alpha}${b}${g}${r}`;
};
const assToRgba = (ass) => {
if (!ass || ass.length < 10) return "transparent";
const a = (255 - parseInt(ass.slice(2, 4), 16)) / 255;
const b = parseInt(ass.slice(4, 6), 16);
const g = parseInt(ass.slice(6, 8), 16);
const r = parseInt(ass.slice(8, 10), 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
const toHex = (ass) => {
if (!ass || ass.length < 10) return "#ffffff";
const b = ass.slice(4, 6);
const g = ass.slice(6, 8);
const r = ass.slice(8, 10);
return `#${r}${g}${b}`;
};
const handleUpdate = async () => {
if (!job?.id) return;
setLoading(true);
try {
const payload = {
font_name: sub.font === "Mono" ? "Geist Mono" : sub.font === "Inter Tight" ? "Inter Tight" : sub.font,
font_size: sub.size,
max_words_per_line: sub.maxWords,
position: sub.position,
margin_v: sub.marginV,
color_text: sub.color_text,
color_outline: sub.color_outline,
color_back: sub.color_back,
color_highlight: sub.color_highlight,
subtitle_mode: sub.mode,
};
const res = await fetch(`/api/jobs/${job.id}/subtitles/style`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to update");
const data = await res.json();
if (data.video_url && onUpdateJob) {
onUpdateJob({ ...job, videoUrl: data.video_url });
}
} catch (e) {
alert(e.message);
} finally {
setLoading(false);
}
};
const POSITIONS = uiLang === "en" ? [
{ id: "top", label: "Top" },
{ id: "middle", label: "Mid" },
{ id: "bottom", label: "Bottom" },
] : [
{ id: "top", label: "Trên" },
{ id: "middle", label: "Giữa" },
{ id: "bottom", label: "Dưới" },
];
const COLOR_LABELS = uiLang === "en" ? {
color_text: "Text",
color_outline: "Outline",
color_back: "Background",
color_highlight: "Highlight",
} : {
color_text: "Chữ",
color_outline: "Viền",
color_back: "Nền",
color_highlight: "Nổi bật",
};
// ── Phase: idle → user has not added subtitles yet. Show CTA.
if (phase === "idle") {
return (
{uiLang === "en" ? "Subtitles" : "Phụ đề"}
{uiLang === "en" ? "Do you want to add subtitles?" : "Bạn có muốn thêm phụ đề?"}
{uiLang === "en"
? "CapCut shows per-word yellow pill highlight; Plain shows the full sentence only. Takes ~1-2 minutes."
: "CapCut hiển thị nổi bật màu vàng cho từng từ; Plain chỉ hiển thị câu đầy đủ. Mất khoảng 1-2 phút."}
setSub({ ...sub, mode: v })} options={SUBTITLE_MODES}/>
{addError && (
{addError}
)}
{uiLang === "en" ? "Add subtitles" : "Thêm phụ đề"}
);
}
// ── Phase: adding → background pipeline running. Show progress.
if (phase === "adding") {
return (
{uiLang === "en" ? "Subtitles" : "Phụ đề"}
{uiLang === "en" ? "Transcribing voiceover and burning subtitles…" : "Đang tạo chữ từ lời thoại và ghép phụ đề…"}
{uiLang === "en" ? "The video will reload automatically when ready." : "Video sẽ tự động tải lại khi hoàn chỉnh."}
);
}
// ── Phase: done → full styling controls (restyle existing subtitles).
return (
{uiLang === "en" ? "Subtitle Styling" : "Định dạng phụ đề"}
{/* live preview window */}
{/* fake film grain */}
{/* fake astronaut silhouette */}
{/* satellite silhouette */}
{/* aspect overlay corners */}
{[[0,0,1,1],[1,0,-1,1],[0,1,1,-1],[1,1,-1,-1]].map(([x,y,dx,dy], i) => (
))}
{/* subtitle bar */}
0)
? `
-1px -1px 0 ${assToRgba(sub.color_outline)},
1px -1px 0 ${assToRgba(sub.color_outline)},
-1px 1px 0 ${assToRgba(sub.color_outline)},
1px 1px 0 ${assToRgba(sub.color_outline)},
0 0 4px ${assToRgba(sub.color_outline)}`
: "none",
whiteSpace: "nowrap",
lineHeight: 1.2,
}}>
{"Voyager 1 and Voyager 2 are still transmitting today.".split(" ").map((w, i) => {
const isActive = sub.mode === "capcut" && i === 5;
return (
{w}
);
})}
setSub({...sub, font: v})}
options={[
{ value: "Arial", label: "Arial" },
{ value: "Geist", label: "Geist Sans" },
{ value: "Inter Tight", label: "Inter Tight" },
{ value: "Mono", label: "Geist Mono" },
]}/>
setSub({...sub, position: v})} options={POSITIONS}/>
setSub({ ...sub, mode: v })} options={SUBTITLE_MODES}/>
setSub({...sub, size: v})} min={24} max={120} step={2} suffix="px"/>
setSub({...sub, maxWords: v})} min={1} max={12}/>
setSub({...sub, marginV: v})} min={0} max={600} step={10} suffix="px"/>
{uiLang === "en" ? "Colors" : "Màu sắc"}
{[
{ label: COLOR_LABELS.color_text, key: "color_text", alpha: "00" },
{ label: COLOR_LABELS.color_outline, key: "color_outline", alpha: "00" },
{ label: COLOR_LABELS.color_back, key: "color_back", alpha: "90" },
{ label: COLOR_LABELS.color_highlight, key: "color_highlight", alpha: "00" },
].map(c => (
setSub(prev => ({ ...prev, [c.key]: toAssColor(e.target.value, c.alpha) }))}
style={{ width: 26, height: 26, padding: 0, border: "none", borderRadius: 4, cursor: "pointer", background: "none" }}
/>
{c.label}
))}
{loading ? (
) : (uiLang === "en" ? "Apply Style" : "Áp dụng định dạng")}
);
}
Object.assign(window, { OutputScreen, SCENES });