import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { videoEditApi, videosApi, type LayoutInfo, type LayoutSchema } from "../api/endpoints"; import type { LegacyVideo, ScriptScene, VideoCatalog, VideoConfig, VideoJobState, VideoProject, VideoStatus } from "../api/types"; import { CheckIcon, DownloadIcon, SparkleIcon } from "../components/icons/Icons"; import { ConfirmButton, ErrorText, errorMessage, Loading, Modal, PageHeader, StatusPill, Tabs, VIDEO_UPDATING } from "../components/ui"; import { ImagesPanel, VoicePanel, type PanelProps } from "../components/video/EditorPanels"; import { LookPanel } from "../components/video/SettingsPanel"; import { SceneList } from "../components/video/SceneList"; import { PhoneIcon, ScreenIcon } from "../components/video/parts"; import { usePoll } from "../hooks/usePoll"; import { finished, jobPercent } from "../components/video/jobState"; import { useUpgrade } from "../hooks/useUpgrade"; const STEPS = ["Reading the source", "Writing the script", "Building scenes", "Finishing"]; const REVIEW = new Set(["awaiting_stock_footage_review", "awaiting_script_review"]); type Panel = "scenes" | "images" | "audio" | "settings"; function busyMessage(e: unknown): string { return e instanceof ApiError && e.status === 409 ? "Another change is still running, try again in a moment." : errorMessage(e); } type Running = { label: string; poll: () => Promise } | null; /** One line on what the job is doing now: "Scene 3 of 14", or for a translation which pass it is on. */ function jobDetail(s: VideoJobState | null): string | null { if (!s) return null; const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null); const total = num(s.total) ?? num(s.total_scenes); const done = num(s.completed) ?? num(s.processed_scenes) ?? num(s.processed); if (s.kind === "language_change") { // Two passes over the scenes: the copy, then every voiceover (blog2video counts both in total). const scenes = total ? total / 2 : null; if (s.phase === "translating") return `Translating the text${scenes && done !== null ? ` · scene ${Math.min(done + 1, scenes)} of ${scenes}` : ""}`; if (s.phase === "voiceover") return `Recording voiceovers${scenes && done !== null ? ` · scene ${Math.min(Math.max(done - scenes, 0) + 1, scenes)} of ${scenes}` : ""}`; } return total && done !== null ? `Scene ${Math.min(done + 1, total)} of ${total}` : null; } /** How far a job is, 0-100, from whichever shape its status endpoint uses; null when it does not say. */ // How a job's progress is drawn: "bar" = the full progress screen in place of the editor; "inline" = the editor stays // (rendering: the preview is final, the Rendering pill under it shows progress). A new kind of job adds its own. type JobView = "bar" | "inline"; type JobInfo = { label: string; title: string; tip?: string; view: JobView; poll?: (id: string) => Promise }; /** Edit jobs on a finished video, keyed by the status blog2video (and so our artifact) has while one runs. The label * is what startJob is given when the job starts from this tab; poll resumes it after a reload or from another tab. */ const JOBS: Record = { regenerating: { label: "Switching template", title: "Regenerating scene layouts", view: "bar", tip: "Switch the template, colors and fonts anytime from the Settings tab.", poll: (id) => videoEditApi.templateStatus(id) }, voice_regenerating: { label: "Changing the voice", title: "Re-recording the voiceover", view: "bar", tip: "Preview any voice from the Voices page before switching.", poll: (id) => videoEditApi.voiceStatus(id) }, language_regenerating: { label: "Translating", title: "Translating your video", view: "bar", tip: "Text, narration and voiceover are all translated.", poll: (id) => videoEditApi.languageStatus(id) }, rendering: { label: "Rendering", title: "Rendering the MP4", view: "inline", poll: (id) => videoEditApi.renderStatus(id) }, script_regenerating: { label: "Refreshing the script", title: "Refreshing the script", view: "bar", tip: "Narration and voiceovers stay as they are.", poll: (id) => videoEditApi.refreshStatus(id) }, }; /** Jobs with no status of their own on blog2video: only shown when started from this tab. */ const LOCAL_JOBS: JobInfo[] = [ { label: "Deleting the voiceover", title: "Removing the voiceover", view: "bar", tip: "Render again afterwards to get a silent MP4.", poll: (id) => videoEditApi.voiceStatus(id) }, { label: "Adding a scene", title: "Writing the new scene", tip: "You can edit any scene once it's added.", view: "bar" }, ]; const jobByLabel = (label: string): JobInfo => Object.values(JOBS).find((j) => j.label === label) ?? LOCAL_JOBS.find((j) => j.label === label) ?? { label, title: label, view: "bar" }; /** blog2video-style progress screen for a background job: shown in place of the editor until the job ends. */ function JobProgress({ label, name, state }: { label: string; name: string; state: VideoJobState | null }) { const known = jobByLabel(label); // Resumed from the stored status, a voiceover delete arrives as a voice job: blog2video's kind tells them apart. const copy = state?.kind === "delete" ? jobByLabel("Deleting the voiceover") : known; const pct = jobPercent(state); const detail = jobDetail(state); const step = typeof state?.current_step === "string" && state.current_step ? state.current_step : null; return (

{copy.title}

{name}

{detail &&

{detail}

} {copy.tip && (
Tip

{copy.tip}

)}
); } export default function VideoEditor() { const { id = "" } = useParams(); const navigate = useNavigate(); const { openUpgrade } = useUpgrade(); const [loaded, setProject] = useState(null); const legacy = loaded?.legacy ? loaded : null; const project = loaded?.legacy ? null : loaded; const [catalog, setCatalog] = useState(null); const [config, setConfig] = useState(null); const [notFound, setNotFound] = useState(false); const [running, setRunning] = useState(null); const [genKey, setGenKey] = useState(0); // bumped after a review is approved, to resume polling const [formatTo, setFormatTo] = useState(null); // the format switch waiting for confirmation const [downloading, setDownloading] = useState(false); // the download modal is open const [stoppingRender, setStoppingRender] = useState(false); // the "stop rendering?" warning is open const isRendering = running?.label === "Rendering"; useEffect(() => { if (!isRendering) setStoppingRender(false); // the render ended: a warning left open is no longer needed }, [isRendering]); // The running render was started to be downloaded: save the MP4 when it is up. Kept for this tab (sessionStorage), // so leaving the editor mid-render and coming back still downloads it. const autoKey = `vw-autodownload:${id}`; const autoDownload = useRef(false); useEffect(() => { try { autoDownload.current = sessionStorage.getItem(autoKey) === "1"; } catch { autoDownload.current = false; } }, [autoKey]); const setAutoDownload = useCallback((on: boolean) => { autoDownload.current = on; try { if (on) sessionStorage.setItem(autoKey, "1"); else sessionStorage.removeItem(autoKey); } catch { /* private mode: the ref alone still covers this visit */ } }, [autoKey]); const [layoutSchema, setLayoutSchema] = useState(null); // default font sizes per layout const [layoutNames, setLayoutNames] = useState | null>(null); // display names per layout const [layoutInfo, setLayoutInfo] = useState(null); // layouts and their variants const [panel, setPanel] = useState("scenes"); const [error, setError] = useState(null); const reload = useCallback( () => videosApi.get(id).then((p) => { setProject(p); setError((err) => (err?.startsWith(VIDEO_UPDATING) ? null : err)); }).catch((e) => { if (e instanceof ApiError && e.status === 404) setNotFound(true); else setError(errorMessage(e)); }), [id], ); useEffect(() => { reload(); videosApi.catalog().then(setCatalog).catch(() => undefined); videosApi.config().then(setConfig).catch(() => undefined); }, [reload]); // The template's layout names and default font sizes, again after a template switch. On failure the editor falls // back to title-cased ids and generic sizes. const template = project?.project.template; useEffect(() => { if (!template) return; let live = true; videoEditApi.layouts(id).then((l) => { if (!live) return; setLayoutSchema(l.layout_prop_schema ?? null); setLayoutNames(l.layout_names ?? null); setLayoutInfo(l); }).catch(() => undefined); return () => { live = false; }; }, [id, template]); // Generation progress: poll until ready, failed, or waiting on a review. const gen = usePoll(notFound || legacy ? null : () => videosApi.status(id), { intervalMs: 4000, key: `${id}:${genKey}`, // Also stops on an edit job's status: that job has its own poll (below), not this one. until: (s) => !!s.legacy || s.ready || !!s.error || s.status === "failed" || REVIEW.has(s.status) || s.status in JOBS, }); const status = gen.data; useEffect(() => { if (gen.done) reload(); }, [gen.done, reload]); // One background job at a time (template, voice, language, add scene, script refresh, render). const job = usePoll(running ? running.poll : null, { intervalMs: 3000, key: running, until: finished }); useEffect(() => { if (!job.done || !running) return; const err = job.data?.error; if (err) setError(`${running.label} failed: ${err}`); // no banner on success: the reloaded video shows it if (running.label === "Rendering" && autoDownload.current) { setAutoDownload(false); if (!err) { const ready = job.data?.r2_video_url; void downloadWhenUploaded(typeof ready === "string" ? ready : null); } } setRunning(null); reload(); setGenKey((k) => k + 1); // asks /status again, which also brings our stored status back from the job's }, [job.done, job.data, running, reload, setAutoDownload]); // An edit job is running (started in another tab, or before a reload): show its progress screen. Right away from // our stored status (set when the job started) on opening the editor, without waiting for the first /status answer; // after that, blog2video's answer decides. The job's own poll ends a stale one at once (it reports it finished). const storedJob = project?.artifact.status && project.artifact.status in JOBS ? project.artifact.status : null; const jobStatus = status ? (status.status in JOBS ? status.status : null) : genKey === 0 ? storedJob : null; useEffect(() => { const info = jobStatus ? JOBS[jobStatus] : null; if (!info?.poll || running) return; const poll = info.poll; setRunning({ label: info.label, poll: () => poll(id) }); }, [jobStatus, running, id]); /** True once the change is made and the project reloaded; false when it failed (the error is shown). `done` (a * success line) is accepted for the callers' sake but not shown: the editor shows errors only. */ async function act(fn: () => Promise, _done?: string): Promise { setError(null); try { await fn(); await reload(); return true; } catch (e) { if (e instanceof ApiError && e.status === 404) { // The video changed on blog2video under us (e.g. scenes were rebuilt): show the current version. setError(`${errorMessage(e)}. The video was reloaded; try the change again.`); await reload(); return false; } setError(busyMessage(e)); return false; } } async function startJob(label: string, start: () => Promise, poll: () => Promise) { setError(null); try { await start(); setRunning({ label, poll }); } catch (e) { setError(busyMessage(e)); } } const remove = async () => { await videosApi.remove(id); navigate("/app/archive?tab=videos"); }; if (notFound) { return (
Back to videos
); } if (legacy) return ; if (!project && !status) return
; const title = project?.artifact.title ?? "Video"; const failed = !!status && (!!status.error || status.status === "failed"); // Until the first /status answer, our stored status decides (as for edit jobs), so reopening a video that is still // being made goes straight to its progress screen instead of showing the editor first. const stored = !status && genKey === 0 ? project?.artifact.status : null; // First generation only: an edit job on a finished video has its own progress screen. const generating = status ? !status.ready && !failed && !REVIEW.has(status.status) && !(status.status in JOBS) : stored === "generating" || stored === "pending"; // Stored as waiting on a review or failed: /status says which screen, so the editor stays hidden until it answers. const awaiting = stored === "review" || stored === "failed"; const step: number = status?.step ?? (typeof project?.artifact.content?.step === "number" ? project.artifact.content.step : 1); // The live preview only while blog2video says the video is ready (generated / done, or rendering its MP4). The preview link is minted once // and kept, so it is there in every state; mid-job it would show a half-changed video. Until /status answers, our // stored status decides. const previewReady = status ? (status.ready || ["generated", "done", "rendering"].includes(status.status)) && JOBS[status.status]?.view !== "bar" : ["ready", "rendering"].includes(project?.artifact.status ?? ""); // The pill follows blog2video during an edit job, so it agrees with the screen even before our copy catches up. const pillStatus = status?.status && status.status in JOBS ? status.status : project?.artifact.status; // A running job drawn as the full progress screen (in place of the editor); rendering stays inline. const fullScreenJob = !!running && jobByLabel(running.label).view === "bar"; // Rendered = blog2video's "done" (an MP4 exists), not just "generated" (editable). Until /status answers, the MP4 // link we stored decides. const storedMp4 = project ? project.video_url ?? project.artifact.url ?? null : null; const rendered = (status?.status ?? "") === "done" || !!storedMp4; /** Download the MP4 by opening its R2 link in a new tab: R2 serves it with Content-Disposition: attachment, so the * browser saves the file. If the browser blocks the new tab (it can, after a long render with no click), follow * the link here instead: an attachment does not navigate away, the download just starts. */ function saveMp4(url: string) { const tab = window.open(url, "_blank"); if (tab) { tab.opener = null; return; } const a = document.createElement("a"); a.href = url; a.rel = "noreferrer"; document.body.appendChild(a); a.click(); a.remove(); } const isWebUrl = (u: unknown): u is string => typeof u === "string" && /^https?:\/\//.test(u); async function downloadNow() { const fresh = await videoEditApi.downloadUrl(id).then((r) => r.url).catch(() => null); const url = isWebUrl(fresh) ? fresh : storedMp4; if (url) saveMp4(url); else setError("The MP4 isn't available to download yet. Try rendering it again."); } /** After a render: download once the MP4 is uploaded to R2 (download-url answers 202 / a local path until then). */ async function downloadWhenUploaded(r2: string | null) { // A re-render replaces the file at the same R2 address: a version param (as blog2video's own /download adds) keeps // a cached copy of the previous MP4 from being served. const fresh = (u: string) => `${u}${u.includes("?") ? "&" : "?"}v=${Date.now()}`; if (isWebUrl(r2)) return saveMp4(fresh(r2)); for (let i = 0; i < 40; i++) { const url = await videoEditApi.downloadUrl(id).then((r) => r.url).catch(() => null); if (isWebUrl(url)) return saveMp4(fresh(url)); await new Promise((ok) => setTimeout(ok, 3000)); } setError("The MP4 is rendered but not ready to download yet. Try Download again in a moment."); } const locked = !!running || generating; const portrait = project?.project.aspect_ratio === "portrait"; const summary = project?.project.summary; const aiEdits = config?.limits.ai_edits; const aiEditsLeft = aiEdits ? Math.max(aiEdits.limit - aiEdits.used, 0) : null; const panelProps: PanelProps | null = project ? { id, project: project.project, layoutSchema, layoutNames, layoutInfo, disabled: locked, act, startJob, locked: () => openUpgrade(), } : null; return (
{project && !generating && pillStatus && } {generating && (

Making your video

    {STEPS.map((s, i) => { const n = i + 1; const done = n < step; return (
  1. {done ? : n} {s}
  2. ); })}

You can leave this page; the video keeps going.

{project && }
)} {failed && (

This video could not be made

It was not charged against your videos.

Try again
)} {status?.status === "awaiting_script_review" && ( setGenKey((k) => k + 1)} /> )} {status?.status === "awaiting_stock_footage_review" && project && (

Review stock footage

These clips were picked for your scenes. Keep them, or make the video without them.

{(project.project.assets ?? []).filter((a) => a.asset_type === "video" && a.r2_url).map((a) => (
)} {error && } {/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */} {fullScreenJob && running && } {awaiting &&
} {project && panelProps && !generating && !awaiting && !REVIEW.has(status?.status ?? "") && !fullScreenJob && (
{project.preview_url && previewReady ? (
{/* Keyed on the format: a switch reloads it in the new shape. Other edits need no remount: blog2video pushes every change to the embed over its live channel and the player refetches by itself (forcing a remount as well made it load twice). */}