import { useEffect, useRef, useState } from "react"; import { videoEditApi } from "../../api/endpoints"; import type { VideoScene } from "../../api/types"; import { ChevronIcon, FilmIcon, SparkleIcon, TrashIcon, UploadIcon } from "../icons/Icons"; import { errorMessage, Modal } from "../ui"; import type { PanelProps } from "./EditorPanels"; import { FontSliders, SceneEditModal } from "./SceneEditModal"; import { AiImageModal, AiReviewModal, AssetPickerModal, ChoiceModal, IMAGE_MAX, IMAGE_TYPES, RemoveMediaModal, StockSearchModal } from "./SceneMedia"; import { fontDefaults, layoutName, withProps } from "./sceneCode"; const GROUP = 5; const sorted = (scenes: VideoScene[]) => [...scenes].sort((a, b) => a.order - b.order); /** An AI image being made for a scene. It is not on the scene until the user keeps it. */ type AiJob = { status: "running" } | { status: "ready"; base64: string } | { status: "failed"; error: string }; /** Scenes as rows, like blog2video's editor: grouped by five, each row expands to its details. */ export function SceneList(p: PanelProps & { aiEditsLeft: number | null }) { const scenes = sorted(p.project.scenes); const [expanded, setExpanded] = useState(null); const [openGroup, setOpenGroup] = useState(0); // one group of five open at a time, the first to start const [editing, setEditing] = useState(null); const [adding, setAdding] = useState(null); // position for the new scene const [deleting, setDeleting] = useState(null); // scene in the delete warning // AI images per scene id. Kept here, not in the scene's row, so collapsing the row does not lose one; leaving the // editor mid-way does (as on blog2video). const [aiJobs, setAiJobs] = useState>({}); const [reviewing, setReviewing] = useState(null); // scene id whose generated image is on screen const summary = p.project.summary; /** Runs in the background (not through act, so other edits carry on); the review opens when it is done. */ async function generateAi(sceneId: number, prompt: string) { setAiJobs((j) => ({ ...j, [sceneId]: { status: "running" } })); try { const out = await videoEditApi.generateImage(p.id, sceneId, prompt); setAiJobs((j) => ({ ...j, [sceneId]: { status: "ready", base64: out.image_base64 } })); setReviewing((r) => r ?? sceneId); } catch (e) { setAiJobs((j) => ({ ...j, [sceneId]: { status: "failed", error: errorMessage(e) } })); } } function closeReview(sceneId: number) { setAiJobs(({ [sceneId]: _gone, ...rest }) => rest); // Another scene's image may be waiting too. const next = Object.entries(aiJobs).find(([id, j]) => Number(id) !== sceneId && j.status === "ready"); setReviewing(next ? Number(next[0]) : null); } const reviewScene = reviewing === null ? undefined : scenes.find((sc) => sc.id === reviewing); const reviewJob = reviewing === null ? undefined : aiJobs[reviewing]; const groups: VideoScene[][] = []; for (let i = 0; i < scenes.length; i += GROUP) groups.push(scenes.slice(i, i + GROUP)); return (

{p.project.name}

{summary?.scenes ?? scenes.length} scenes{summary ? `: ${summary.images} images, ${summary.clips ?? 0} stock clips` : ""}. Click Edit on any scene to change it.
{groups.map((group, g) => { const first = g * GROUP + 1; const open = openGroup === g; return (
{open && group.map((s) => { const i = scenes.indexOf(s); const isOpen = expanded === s.id; return (
{i + 1}
Scene Audio {Number(s.duration_seconds ?? 0).toFixed(1)}s
{isOpen && generateAi(s.id, prompt)} onReviewAi={() => setReviewing(s.id)} onDismissAi={() => setAiJobs(({ [s.id]: _gone, ...rest }) => rest)} />}
); })}
); })} {scenes.length === 0 && ( )} {editing && ( s.id === editing.id)} template={p.project.template} aiEditsLeft={p.aiEditsLeft} onClose={() => setEditing(null)} layoutInfo={p.layoutInfo} layoutNames={p.layoutNames} onSaved={(msg) => { setEditing(null); p.act(async () => undefined, msg); }} /> )} {reviewScene && reviewJob?.status === "ready" && ( closeReview(reviewScene.id)} /> )} {deleting && ( sc.id === deleting.id) + 1} onClose={() => setDeleting(null)} /> )} {adding !== null && ( setAdding(null)} onAdd={(prompt) => { setAdding(null); return p.startJob("Adding a scene", () => videoEditApi.addScene(p.id, prompt, adding), () => videoEditApi.addSceneStatus(p.id)); }} /> )}
); } /** Delete one scene. Stays open (and loading) until it is deleted and the video reloaded; act then refreshes the * live preview and the row is gone from the list. */ function DeleteSceneModal(p: PanelProps & { scene: VideoScene; number: number; onClose: () => void }) { const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); async function remove() { setBusy(true); setFailed(false); const ok = await p.act(() => videoEditApi.deleteScene(p.id, p.scene.id)); setBusy(false); if (ok) p.onClose(); else setFailed(true); } return ( // While the delete runs, Escape, the backdrop and the close button do nothing. undefined : p.onClose}>

"{p.scene.title || `Scene ${p.number}`}" and its voiceover will be deleted from this video. This can't be undone.

{failed &&

Couldn't delete the scene. Try again.

}
); } function AddSceneModal({ position, onClose, onAdd }: { position: number; onClose: () => void; onAdd: (prompt: string) => void }) { const [prompt, setPrompt] = useState(""); return (