// enhance-screen.jsx — Video → Enhance flow (talking-head in, captions/emoji/zoom/b-roll out). // Self-contained: upload → analyze → review/edit plan → render → output. // Reuses the AutoCut shell + CSS design tokens. Registered as a global component. const TXT_ENH = { vi: { title: "Làm đẹp video người nói", subtitle: "Tải video lên — AI tự thêm chữ nhấn, emoji, zoom và b-roll minh hoạ.", pickVideo: "Chọn video (.mp4, .mov, .webm)", dropHint: "Kéo thả video vào đây hoặc bấm để chọn", language: "Ngôn ngữ thoại", analyze: "Phân tích video", analyzing: "Đang phân tích…", probing: "Đang đọc thông số video…", uploading: "Đang tải video lên…", reviewTitle: "Xem & chỉnh kế hoạch hiệu ứng", reviewHint: "Sửa, xoá hoặc thêm hiệu ứng trước khi render.", keywords: "Chữ nhấn (keyword)", emojis: "Emoji", zooms: "Zoom", broll: "B-roll (hình minh hoạ)", graphics: "Scene giải thích (Graphic)", graphicDesc: "Mô tả visual", render: "Render video", rendering: "Đang render…", fetching: "Đang tải hình/clip b-roll…", generatingGraphics: "Đang tạo scene giải thích…", done: "Hoàn tất!", download: "Tải video", newJob: "Làm video khác", add: "Thêm", empty: "Chưa có", text: "Chữ", start: "Bắt đầu", end: "Kết thúc", style: "Kiểu", size: "Cỡ", pos: "Vị trí", anim: "Hiệu ứng", scale: "Mức zoom", source: "Nguồn", query: "Mô tả/từ khoá", transition: "Chuyển cảnh", emoji: "Emoji", sec: "giây", failed: "Thất bại", }, en: { title: "Enhance talking-head video", subtitle: "Upload a video — AI adds keyword pop-ups, emojis, zooms and b-roll.", pickVideo: "Choose a video (.mp4, .mov, .webm)", dropHint: "Drag a video here or click to choose", language: "Spoken language", analyze: "Analyze video", analyzing: "Analyzing…", probing: "Reading video metadata…", uploading: "Uploading video…", reviewTitle: "Review & edit the effect plan", reviewHint: "Edit, delete or add cues before rendering.", keywords: "Keyword pop-ups", emojis: "Emojis", zooms: "Zooms", broll: "B-roll", graphics: "Graphic scenes", graphicDesc: "Visual description", render: "Render video", rendering: "Rendering…", fetching: "Fetching b-roll assets…", generatingGraphics: "Generating graphic scenes…", done: "Done!", download: "Download", newJob: "New video", add: "Add", empty: "None yet", text: "Text", start: "Start", end: "End", style: "Style", size: "Size", pos: "Pos", anim: "Anim", scale: "Zoom", source: "Source", query: "Query/prompt", transition: "Transition", emoji: "Emoji", sec: "s", failed: "Failed", }, }; const ENH_POLL_MS = 2000; const KW_STYLE_OPTS = ["default", "money", "warning"]; const KW_SIZE_OPTS = ["s", "m", "l", "xl"]; const KW_POS_OPTS = ["top", "bottom"]; const EMOJI_ANIM_OPTS = ["pop", "bounce", "float"]; const BROLL_SOURCE_OPTS = ["unsplash", "imagen", "veo"]; const BROLL_TRANS_OPTS = ["zoom", "whip", "fade"]; // ── small styled primitives ────────────────────────────────────────────── const enhInput = { background: "var(--bg-1)", border: "1px solid var(--line)", color: "var(--fg-0)", borderRadius: "var(--radius)", padding: "6px 8px", fontSize: 12.5, fontFamily: "var(--font-sans)", width: "100%", boxSizing: "border-box", }; const enhBtn = (primary) => ({ padding: "10px 18px", fontSize: 13.5, fontWeight: 600, borderRadius: "var(--radius)", cursor: "pointer", border: "none", background: primary ? "var(--accent)" : "var(--bg-3)", color: primary ? "var(--accent-fg)" : "var(--fg-1)", }); function EnhField({ label, children }) { return ( ); } function EnhSelect({ value, options, onChange }) { return ( ); } function EnhNum({ value, onChange, step }) { return ( onChange(parseFloat(e.target.value))} style={enhInput} /> ); } // ── cue row (a flex row of fields + delete) ──────────────────────────────── function CueRow({ children, onDelete }) { return (
{children}
); } function CueSection({ title, count, onAdd, children }) { return (

{title} ({count})

{children}
); } function EnhanceScreen({ uiLang }) { const T = TXT_ENH[uiLang] || TXT_ENH.vi; const [phase, setPhase] = React.useState("upload"); // upload|processing|review|rendering|done|error const [file, setFile] = React.useState(null); const [language, setLanguage] = React.useState(uiLang === "vi" ? "Vietnamese" : "English"); const [jobId, setJobId] = React.useState(null); const [status, setStatus] = React.useState(null); const [plan, setPlan] = React.useState(null); const [meta, setMeta] = React.useState(null); const [videoUrl, setVideoUrl] = React.useState(null); const [error, setError] = React.useState(null); const fileInputRef = React.useRef(null); // ── polling ────────────────────────────────────────────────────────────── React.useEffect(() => { if (!jobId || (phase !== "processing" && phase !== "rendering")) return; let alive = true; const tick = async () => { try { const res = await fetch(`/api/enhance/${jobId}/status`); if (!res.ok) throw new Error(`status ${res.status}`); const s = await res.json(); if (!alive) return; setStatus(s.status); if (s.meta) setMeta(s.meta); if (s.status === "AWAITING_REVIEW") { setPlan(s.plan || { keywords: [], emojis: [], zooms: [], broll: [], graphics: [] }); setPhase("review"); return; } if (s.status === "COMPLETED") { setVideoUrl((s.video_url || `/files/${jobId}/output.mp4`) + `?t=${Date.now()}`); setPhase("done"); return; } if (s.status === "FAILED") { setError(s.error || "Failed"); setPhase("error"); return; } setTimeout(tick, ENH_POLL_MS); } catch (e) { if (alive) setTimeout(tick, ENH_POLL_MS); } }; const id = setTimeout(tick, 400); return () => { alive = false; clearTimeout(id); }; }, [jobId, phase]); // ── actions ──────────────────────────────────────────────────────────── const startAnalyze = async () => { if (!file) return; setError(null); setPhase("processing"); setStatus("PENDING"); try { const fd = new FormData(); fd.append("file", file); fd.append("language", language); const res = await fetch("/api/enhance", { method: "POST", body: fd }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); const data = await res.json(); setJobId(data.job_id); } catch (e) { setError(String(e.message || e)); setPhase("error"); } }; const startRender = async () => { try { const pr = await fetch(`/api/enhance/${jobId}/plan`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(plan), }); if (!pr.ok) throw new Error(`plan ${pr.status}: ${await pr.text()}`); const cr = await fetch(`/api/enhance/${jobId}/continue`, { method: "POST" }); if (!cr.ok) throw new Error(`continue ${cr.status}`); setStatus("FETCHING_ASSETS"); setPhase("rendering"); } catch (e) { setError(String(e.message || e)); setPhase("error"); } }; const reset = () => { setPhase("upload"); setFile(null); setJobId(null); setStatus(null); setPlan(null); setMeta(null); setVideoUrl(null); setError(null); }; // ── plan editing helpers (immutable) ────────────────────────────────────── const updateCue = (key, idx, field, val) => setPlan((p) => ({ ...p, [key]: p[key].map((c, i) => i === idx ? { ...c, [field]: val } : c), })); const deleteCue = (key, idx) => setPlan((p) => ({ ...p, [key]: p[key].filter((_, i) => i !== idx) })); const addCue = (key, blank) => setPlan((p) => ({ ...p, [key]: [...(p[key] || []), blank] })); const wrap = { maxWidth: 880, margin: "0 auto", padding: "0 24px" }; // ── UPLOAD ──────────────────────────────────────────────────────────────── if (phase === "upload") { return (

{T.title}

{T.subtitle}

fileInputRef.current && fileInputRef.current.click()} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) setFile(f); }} style={{ border: "1.5px dashed var(--line-strong)", borderRadius: "var(--radius-lg)", padding: "40px 24px", textAlign: "center", cursor: "pointer", background: "var(--bg-1)", transition: "border-color .15s", }} > setFile(e.target.files[0] || null)} />
🎬
{file ? (
{file.name}
{(file.size / (1024 * 1024)).toFixed(1)} MB
) : (
{T.dropHint}
)}
); } // ── PROCESSING / RENDERING ────────────────────────────────────────────── if (phase === "processing" || phase === "rendering") { const labelMap = { PENDING: T.uploading, PROBING: T.probing, ANALYZING: T.analyzing, FETCHING_ASSETS: T.fetching, GENERATING_GRAPHICS: T.generatingGraphics, RENDERING: T.rendering, }; return (
{labelMap[status] || T.analyzing}
{meta && (
{meta.width}×{meta.height} · {meta.fps}fps · {Math.round(meta.duration_seconds)}{T.sec}
)}
); } // ── DONE ──────────────────────────────────────────────────────────────── if (phase === "done") { return (

✅ {T.done}

); } // ── ERROR ──────────────────────────────────────────────────────────────── if (phase === "error") { return (
⚠ {T.failed}
{error}
); } // ── REVIEW (plan editor) ────────────────────────────────────────────────── const p = plan || { keywords: [], emojis: [], zooms: [], broll: [], graphics: [] }; return (

{T.reviewTitle}

{T.reviewHint}

addCue("keywords", { text: "", start: 0, end: 2, style: "default", size: "m", pos: "top" })}> {p.keywords.length === 0 && } {p.keywords.map((c, i) => ( deleteCue("keywords", i)}>
updateCue("keywords", i, "text", e.target.value)} style={enhInput} />
updateCue("keywords", i, "start", v)} />
updateCue("keywords", i, "end", v)} />
updateCue("keywords", i, "style", v)} />
updateCue("keywords", i, "size", v)} />
updateCue("keywords", i, "pos", v)} />
))}
addCue("emojis", { emoji: "🔥", start: 0, anim: "pop", x: 0.85, y: 0.18 })}> {p.emojis.length === 0 && } {p.emojis.map((c, i) => ( deleteCue("emojis", i)}>
updateCue("emojis", i, "emoji", e.target.value)} style={{ ...enhInput, textAlign: "center", fontSize: 18 }} />
updateCue("emojis", i, "start", v)} />
updateCue("emojis", i, "anim", v)} />
))}
addCue("zooms", { start: 0, end: 2, scale: 1.15, focus_x: 0.5, focus_y: 0.4 })}> {p.zooms.length === 0 && } {p.zooms.map((c, i) => ( deleteCue("zooms", i)}>
updateCue("zooms", i, "start", v)} />
updateCue("zooms", i, "end", v)} />
updateCue("zooms", i, "scale", v)} />
))}
addCue("broll", { start: 0, end: 8, source: "unsplash", query: "", transition: "zoom" })}> {p.broll.length === 0 && } {p.broll.map((c, i) => ( deleteCue("broll", i)}>
updateCue("broll", i, "query", e.target.value)} style={enhInput} />
updateCue("broll", i, "start", v)} />
updateCue("broll", i, "end", v)} />
updateCue("broll", i, "source", v)} />
updateCue("broll", i, "transition", v)} />
))}
addCue("graphics", { start: 0, end: 9, description: "", scene_tsx: null })}> {(p.graphics || []).length === 0 && } {(p.graphics || []).map((c, i) => ( deleteCue("graphics", i)}>
updateCue("graphics", i, "start", v)} />
updateCue("graphics", i, "end", v)} />