/** * App shell — global state, playback clock, render queue, composition (PRD §7.2.1). * * Global state = the working EpisodeDocument (local model). Selection model is * `{type,id}`; the playback clock persists the scrub time to localStorage. * Keyboard: Space = play/pause, ⌘K = command palette. * * Render + Console actions are wired to the REAL backend via the dev-server * middleware (see `vite.config.ts`): `doRender` -> POST /api/render (the render * pipeline), Console Command mode -> POST /api/scene (the agent-native Scene-Tool * CLI: validated mutation commits / rejections). This is the dev-only local studio * control surface; the user's coding agent remains the director. */ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchDocument, fetchHistory, fetchExistingRender, runRender, runSceneCommand } from "./state/backend"; import { documentExists, mapDocument, mapHistory, type RuntimeDocument } from "./state/mapDocument"; import { Topbar } from "./components/Topbar"; import { Outliner } from "./components/Outliner"; import { Inspector } from "./components/Inspector"; import { Stage } from "./components/Stage"; import { Timeline } from "./components/Timeline"; import { Console, type ConsoleApi } from "./components/Console"; import { Palette } from "./components/Palette"; import { Icon } from "./components/Icon"; import { fmt } from "./state/util"; import type { EpisodeDocument, Selection, SelectionType, Toast, Turn, ViewMode } from "./state/types"; /** Empty view model — used until the real document is fetched, and when none exists. */ const EMPTY_DOC: EpisodeDocument = { title: "Untitled scene", cast: [], sets: [], props: [], shots: [], beats: [], camera: [], gestures: [], fx: [], DUR: 0, // M7 — an empty scene has no graded cast → previz floor. fidelity: { grade: "C", previz: true, characters: [], reason: "previz: no characters in the scene" } }; /** The view-model collection a selection type indexes into. */ function selKey(t: SelectionType): "shots" | "cast" | "sets" | "props" { return t === "shot" ? "shots" : t === "cast" ? "cast" : t === "set" ? "sets" : "props"; } export function App() { // The single source of truth is the REAL working document, fetched on mount and // re-fetched after every validated mutation / render so the UI stays in sync. const [data, setData] = useState(EMPTY_DOC); const [docExists, setDocExists] = useState(false); const [sel, setSel] = useState(null); const [hidden, setHidden] = useState>(() => new Set()); const [time, setTime] = useState(() => { const s = parseFloat(localStorage.getItem("aura.time") || ""); return isNaN(s) ? 0 : s; }); const [playing, setPlaying] = useState(false); const [viewMode, setViewMode] = useState("Render"); const [transcript, setTranscript] = useState([]); const [rendering, setRendering] = useState(false); const [renderPct, setRenderPct] = useState(0); // The most recent REAL render output (served under /preview/*) loaded into the Stage. const [renderVideo, setRenderVideo] = useState(null); const [renderPoster, setRenderPoster] = useState(null); const [toast, setToast] = useState(null); const [paletteOpen, setPaletteOpen] = useState(false); // "Generate new scene from a prompt" — runs the real `new --prompt` Scene-Tool command // (the same thing the CLI `scene new` does) and re-hydrates the whole UI. No commands, // no AI key: the deterministic Director builds the cast/set/shots from the sentence. const [newScenePrompt, setNewScenePrompt] = useState(""); const [generating, setGenerating] = useState(false); const [continuing, setContinuing] = useState(false); const consoleApi = useRef({}); const raf = useRef(0); // The live render-progress EventSource — kept in a ref so unmount can close it // (otherwise an in-flight render leaves a perpetually reconnecting connection). const progressEs = useRef(null); // The active toast-dismiss timer — cleared before each new toast so an earlier // timer can't dismiss a later toast early. const toastTimer = useRef(0); const DUR = data.DUR; const hasShots = data.shots.length > 0; const currentShot = hasShots ? data.shots.find((s) => time >= s.start && time < s.start + s.dur) || data.shots[data.shots.length - 1] : null; // Hydrate the UI from the REAL working document (and command history). Called on mount and // after each /api/scene or /api/render so the panels reflect the live document. Returns the // freshly mapped document (or null when none exists) so callers can read post-hydrate values // (e.g. the NEW scene's duration) without waiting for a React state round-trip. const hydrate = useCallback(async (): Promise => { const [doc, hist, render] = await Promise.all([fetchDocument(), fetchHistory(), fetchExistingRender()]); const exists = documentExists(doc as RuntimeDocument & { exists?: boolean }); setDocExists(exists); setTranscript(mapHistory(hist)); // Show an already-rendered video on the Stage immediately (no need to hit Render first). if (render.video) { const bust = `?t=${Date.now()}`; setRenderVideo(render.video + bust); if (render.poster) setRenderPoster(render.poster + bust); } if (!exists) { setData(EMPTY_DOC); setSel(null); return null; } const mapped = mapDocument(doc as RuntimeDocument); setData(mapped); setSel((prev) => { if (prev && mapped[selKey(prev.type)].some((e: { id: string }) => e.id === prev.id)) return prev; return mapped.shots[0] ? { type: "shot", id: mapped.shots[0].id } : null; }); return mapped; }, []); useEffect(() => { void hydrate(); }, [hydrate]); // Close the render-progress EventSource on unmount (it is opened per-render in doRender). useEffect( () => () => { progressEs.current?.close(); progressEs.current = null; }, [] ); useEffect(() => { localStorage.setItem("aura.time", time.toFixed(2)); }, [time]); // playback loop. When a rendered VIDEO is on the Stage, the