import { useState, type CSSProperties } from "react"; import { videoEditApi, type LayoutInfo } from "../../api/endpoints"; import type { VideoScene } from "../../api/types"; import { Dropdown } from "../Dropdown"; import { errorMessage, Modal } from "../ui"; import { ChevronIcon } from "../icons/Icons"; import { isHiddenProp, keyLabel, LayoutFields, layoutFieldDefs, type FieldDef } from "./LayoutFields"; import { baseLayout, FONT, layoutName, MANAGED_PROPS, parseCode, propsOf, withLayout, withProps, wordsAndSeconds, type FontDefaults } from "./sceneCode"; const KEEP = "__keep__"; // blog2video's "keep the current layout" const AUTO = "__auto__"; // ours for "let AI choose": the layout is left out of the request type Mode = "ai" | "manual"; /** Edit one scene, as on blog2video: AI-assisted (narration, AI rewrite, re-record the voiceover) or manual * (title, display text, extra hold, and the layout's own content fields). */ export function SceneEditModal({ id, scene, index, template, aiEditsLeft, layoutInfo, layoutNames, onClose, onSaved }: { id: string; scene: VideoScene; index: number; template: string; aiEditsLeft: number | null; layoutInfo: LayoutInfo | null; layoutNames: Record | null; onClose: () => void; onSaved: (message: string) => void; }) { const props = propsOf(parseCode(scene), template); const [mode, setMode] = useState("ai"); const [narration, setNarration] = useState(scene.narration_text); const [instruction, setInstruction] = useState(""); const [askAi, setAskAi] = useState(false); const [rerecord, setRerecord] = useState(false); const [verbatim, setVerbatim] = useState(true); // Scene layout: KEEP, AUTO or a layout id (a sibling variant of the current one is a Scene style switch). const current = scene.layout ?? ""; const [layout, setLayout] = useState(KEEP); const [title, setTitle] = useState(scene.title); const [display, setDisplay] = useState(scene.display_text ?? ""); const [hold, setHold] = useState(Number(scene.extra_hold_seconds ?? 0)); // Layout content, as blog2video's editor builds it: the fields the template declares for this layout (meta.json), // minus what is edited elsewhere (fonts, image / stock footage). A layout that declares none falls back to its plain // text and number props. const declared = layoutFieldDefs(layoutInfo?.layout_prop_schema, scene.layout); const fields: FieldDef[] = declared.fields.length ? declared.fields : Object.entries(props) .filter(([k, v]) => !MANAGED_PROPS.has(k) && !isHiddenProp(k) && (typeof v === "string" || typeof v === "number")) .map(([k, v]) => ({ key: k, label: keyLabel(k), type: typeof v === "number" ? "number" : "string" })); const [content, setContent] = useState>(() => Object.fromEntries(fields.map((f) => [f.key, props[f.key]]))); const contentChanges = Object.fromEntries(fields.filter((f) => JSON.stringify(content[f.key] ?? null) !== JSON.stringify(props[f.key] ?? null)) .map((f) => [f.key, content[f.key]])); const [open, setOpen] = useState({ settings: true, props: true }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const narrationChanged = narration !== scene.narration_text; const variants = layoutInfo?.layout_variants?.[baseLayout(current)] ?? []; const layoutChanged = layout !== KEEP && layout !== current; // Same family, other style: a plain descriptor update (no AI, text and images kept), unlike a new layout. const styleSwitch = layoutChanged && layout !== AUTO && baseLayout(layout) === baseLayout(current); const asked = askAi && instruction.trim().length > 0; const needsRegen = rerecord || asked || (layoutChanged && !styleSwitch); const aiChanged = narrationChanged || needsRegen || styleSwitch; /** Editing the narration turns re-recording on (as on blog2video): the old audio no longer matches the text. */ function editNarration(text: string) { setNarration(text); if (text !== scene.narration_text) setRerecord(true); } const manualChanged = title !== scene.title || display !== (scene.display_text ?? "") || hold !== Number(scene.extra_hold_seconds ?? 0) || Object.keys(contentChanges).length > 0; async function save() { setBusy(true); setError(null); try { if (mode === "ai") { // As blog2video's own editor: the edited narration first, then (if needed) one regenerate. if (narrationChanged) await videoEditApi.updateScene(id, scene.id, { narration_text: narration }); if (needsRegen) { await videoEditApi.regenerateScene(id, scene.id, { description: asked ? instruction.trim() : undefined, narration_text: narration, regenerate_voiceover: rerecord, voiceover_verbatim: verbatim, // KEEP must be sent: left out, blog2video lets AI pick a new layout for the scene. layout: layout === AUTO ? undefined : layoutChanged ? layout : KEEP, }); } else if (styleSwitch) { await videoEditApi.updateScene(id, scene.id, { remotion_code: withLayout(scene, layout) }); } onSaved("Scene saved."); } else { const remotion_code = withProps(scene, template, { ...contentChanges, // only the fields that changed: font sizes and media props stay as saved }); await videoEditApi.updateScene(id, scene.id, { title, display_text: display, extra_hold_seconds: hold, remotion_code }); onSaved("Scene saved."); } } catch (e) { setError(errorMessage(e)); setBusy(false); } } return (
Editing mode

Pick a tab: use AI-Assisted to let AI change the scene for you, or Manual to edit it yourself.

{(["ai", "manual"] as const).map((m) => ( ))}
{mode === "ai" && ( <> {aiEditsLeft !== null && (

AI edits remaining: {aiEditsLeft}{" "} ({needsRegen ? "this edit uses 1" : "a voiceover, rewrite or new layout uses 1"})

)}
Scene narration {wordsAndSeconds(narration)} This is what's spoken in the voiceover. Edit it directly, or use AI below.