// 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 ? (
); } 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 */}
{fmtSec(t)} / {fmtSec(TOTAL)}
{/* scrubber */}
{ const rect = e.currentTarget.getBoundingClientRect(); const p = (e.clientX - rect.left) / rect.width; setT(p * TOTAL); }} >
· {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 (
Timeline
{scenes.length} scenes {Math.round(TOTAL)}s 24 fps
{/* 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]) => (
{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"}
{files.length === 0 && (
{uiLang === "en" ? "No output file yet" : "Chưa có file kết quả"}
)} {files.map((f) => ( e.currentTarget.style.background = "var(--bg-3)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"} >
{f.ext.toUpperCase()}
{f.name} {f.note && {f.note}}
{f.size}
))}
); } 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) => ( ))}
); } 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}
)}
); } // ── 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(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}
))}
); } Object.assign(window, { OutputScreen, SCENES });