import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { Film, Image as ImageIcon, Video, Wand2, Plus, Save, Layers, Sparkles, Edit3, Play, ArrowLeft, Trash2, Clapperboard, Loader2, CheckCircle2, AlertTriangle, X, Download } from "lucide-react"; import type { JobItem, Project, Scene, Shot, ShotVersion, ProjectPhase } from "../types"; interface ProjectDetailViewProps { project: Project; scenes: Scene[]; shots: Shot[]; jobs: JobItem[]; selectedSceneId: string | null; selectedShotId: string | null; onSelectScene: (id: string) => void; onSelectShot: (id: string | null) => void; onRefresh: () => Promise | void; onBack: () => void; onDeleteScene: (sceneId: string) => Promise | void; onDeleteShot: (shotId: string) => Promise | void; } function mediaUrl(file: string | null | undefined): string | null { if (!file) return null; if (file.startsWith("/") || file.startsWith("http")) return file; return `/media/${file}`; } export function ProjectDetailView({ project, scenes, shots, jobs, selectedSceneId, selectedShotId, onSelectScene, onSelectShot, onRefresh, onBack, onDeleteScene, onDeleteShot, }: ProjectDetailViewProps) { // Derive real phase from shot data, not prop const phase = useMemo(() => { if (shots.length === 0) return "outline"; const anyImage = shots.some((s) => s.image_file); const anyVideo = shots.some((s) => s.video_file); if (anyVideo) return "animate"; if (anyImage) return "generate"; return "outline"; }, [shots]); const [activeRenderId, setActiveRenderId] = useState(null); const [versions, setVersions] = useState([]); const [error, setError] = useState(null); const [renderStatus, setRenderStatus] = useState(() => String(project.metadata?.render_status ?? "none")); const [renders, setRenders] = useState>([]); const [finalVideoUrl, setFinalVideoUrl] = useState(() => { const v = project.metadata?.final_video; return typeof v === "string" ? `/media/${v}` : null; }); const pollRef = useRef | null>(null); useEffect(() => { setRenderStatus(String(project.metadata?.render_status ?? "none")); const v = project.metadata?.final_video; setFinalVideoUrl(typeof v === "string" ? `/media/${v}` : null); }, [project.metadata]); useEffect(() => { if (renderStatus !== "rendering") { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } return; } pollRef.current = setInterval(async () => { try { const res = await fetch(`/api/projects/${project.id}/render`); if (!res.ok) return; const data = await res.json(); setRenderStatus(data.status ?? "none"); if (data.final_video_url) setFinalVideoUrl(data.final_video_url); if (data.renders) { setRenders(data.renders); if (!activeRenderId && data.renders.length > 0) { setActiveRenderId(data.renders[0].id); } } if (data.status !== "rendering") { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } if (data.status === "failed") setError(`Render failed: ${data.render_error ?? "unknown error"}`); } } catch { /* ignore */ } }, 3000); return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; }, [project.id, renderStatus]); async function handleRender() { if (renderStatus === "rendering") return; setError(null); setRenderStatus("rendering"); try { const res = await fetch(`/api/projects/${project.id}/render`, { method: "POST" }); const data = await res.json(); if (!res.ok) throw new Error(data.detail ?? `${res.status}`); } catch (e) { setRenderStatus("failed"); setError(e instanceof Error ? e.message : "Render failed"); } } const selectedScene = useMemo(() => scenes.find((s) => s.id === selectedSceneId) || null, [scenes, selectedSceneId]); const selectedShot = useMemo(() => shots.find((s) => s.id === selectedShotId) || null, [shots, selectedShotId]); const sceneShots = useMemo(() => shots.filter((s) => s.scene_id === selectedSceneId), [shots, selectedSceneId]); // Load versions for the focused shot useEffect(() => { if (!selectedShotId || !selectedSceneId) { setVersions([]); return; } let cancelled = false; fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots/${selectedShotId}/versions`) .then((r) => r.ok ? r.json() : { versions: [] }) .then((data) => { if (!cancelled) setVersions(data.versions || []); }) .catch(() => { if (!cancelled) setVersions([]); }); return () => { cancelled = true; }; }, [project.id, selectedSceneId, selectedShotId, shots]); const [saving, setSaving] = useState(false); const [showRenderConfirm, setShowRenderConfirm] = useState(false); // Look up the active job for a shot by matching prompt IDs function shotJob(shot: Shot): JobItem | undefined { return jobs.find((j) => (shot.image_prompt_id && j.prompt_id === shot.image_prompt_id) || (shot.video_prompt_id && j.prompt_id === shot.video_prompt_id) ); } function isRendering(shot: Shot): boolean { const job = shotJob(shot); return (shot.status === 'rendering_image' || shot.status === 'animating') || (!!job && (job.status === 'pending' || job.status === 'running')); } async function patchShot(shotId: string, patch: Partial) { const shot = shots.find((s) => s.id === shotId); if (!shot) return; setSaving(true); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shotId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!response.ok) throw new Error(`Patch failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save shot"); } finally { setSaving(false); } } async function addShot() { if (!selectedSceneId) return; setSaving(true); const next = sceneShots.length > 0 ? Math.max(...sceneShots.map((s) => s.shot_number)) + 1 : 1; try { const response = await fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shot_number: next, description: "" }), }); if (!response.ok) throw new Error(`Add shot failed: ${response.status}`); const created = await response.json(); await onRefresh(); onSelectShot(created.id); } catch (e) { setError(e instanceof Error ? e.message : "Failed to add shot"); } finally { setSaving(false); } } async function generateImage(shot: Shot) { if (isRendering(shot)) return; setSaving(true); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/generate-image`, { method: "POST" }); if (!response.ok) throw new Error(`Generate failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to generate image"); } finally { setSaving(false); } } async function animateShot(shot: Shot) { if (isRendering(shot)) return; setSaving(true); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/animate`, { method: "POST" }); if (!response.ok) throw new Error(`Animate failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to animate"); } finally { setSaving(false); } } async function selectVersion(version: ShotVersion) { if (!selectedShot) return; try { const response = await fetch(`/api/projects/${project.id}/scenes/${selectedShot.scene_id}/shots/${selectedShot.id}/versions/${version.id}/select`, { method: "POST" }); if (!response.ok) throw new Error(`Select version failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to select version"); } } return (
{/* Top bar */}

Project

{project.title}

{project.aspect_ratio} {project.duration_seconds !== null && ( {project.duration_seconds}s )} {project.status} Films ({renders.length}) {phase !== "outline" && ( )}
{/* Main split: center | right context editor */}
{/* Center: shots in current scene */}
{selectedScene ? ( <>

Scene {selectedScene.scene_number}

{selectedScene.title || "Untitled scene"}

{selectedScene.summary && (

{selectedScene.summary}

)}
{sceneShots.length === 0 ? (

No shots in this scene yet.

Add shots and write image prompts before generating.

) : (
{sceneShots.map((shot) => ( onSelectShot(shot.id)} onGenerateImage={() => generateImage(shot)} onAnimate={() => animateShot(shot)} onDeleteShot={() => onDeleteShot(shot.id)} /> ))}
)} ) : ( )}
{/* Bottom: shot version strip */}
{selectedShot ? `S${selectedScene?.scene_number}·shot ${selectedShot.shot_number} versions` : "Versions"} {!selectedShot && ( Select a shot to see its generated versions. )} {selectedShot && versions.length === 0 && ( No versions yet. Generate to create one. )} {versions.map((version) => { const url = mediaUrl(version.file); const isCurrent = selectedShot?.image_file === version.file || selectedShot?.video_file === version.file; return ( ); })} {selectedShot && ( )}
{/* Right: context-aware editor — styled to match AppSidebar */}
{error && (
setError(null)}> {error}
)} {showRenderConfirm && ( { setShowRenderConfirm(false); handleRender(); }} onCancel={() => setShowRenderConfirm(false)} /> )}
); } function PhaseChip({ phase }: { phase: ProjectPhase }) { if (phase === "outline") { return ( Outline ); } if (phase === "generate") { return ( Generate ); } return ( Animate ); } function OutlineCenter({ project }: { project: Project }) { return (

Outline phase

No scenes in {project.title} yet. Pitch your idea to your agent and it'll draft the structure here, or add the first scene yourself.

); } function RemixCenter({ project, shots }: { project: Project; shots: Shot[] }) { const imageCount = shots.filter((s) => s.image_file).length; const videoCount = shots.filter((s) => s.video_file).length; const totalShots = shots.length; return (

Remix Phase

Images

{imageCount}/{totalShots}

Videos

{videoCount}/{totalShots}

Status

{project.status}

Remixing is editing. Change any prompt, hit regenerate, and iterate until it lands. Your agent can also edit prompts for you.

How Remix works:

{[ { n: 1, title: "Pick a shot", body: "Click any shot card to select it. The right panel shows its prompts." }, { n: 2, title: "Edit the prompt", body: "Change the image prompt or description in the right panel. Save your changes." }, { n: 3, title: "Regenerate", body: "Click Generate or Re-image. The API runs the new prompt on AMD MI300X and returns a fresh image." }, { n: 4, title: "Iterate", body: "Not quite right? Edit the prompt again and regenerate. Each run creates a new version you can compare." }, ].map((step) => (
{step.n}

{step.title}

{step.body}

))}

API: POST /api/projects/{'{projectId}'}/scenes/{'{sceneId}'}/shots/{'{shotId}'}/generate-image — regenerates with the current prompt. Or ask your agent: "Remix shot 2 with a darker mood."

); } function AnimateCenter({ project, shots }: { project: Project; shots: Shot[] }) { const imageCount = shots.filter((s) => s.image_file).length; const videoCount = shots.filter((s) => s.video_file).length; const totalShots = shots.length; return (

Animate Phase

Images

{imageCount}/{totalShots}

Videos

{videoCount}/{totalShots}

Status

{project.status}

All images are generated. Now you can animate any shot into a video clip. Pick a shot and click Animate.

How Animate works:

{[ { n: 1, title: "Pick a shot", body: "Click any shot card that has an image." }, { n: 2, title: "Click Animate", body: "The API sends the image to Wan 2.2 I2V on AMD MI300X and returns a video clip." }, { n: 3, title: "Iterate", body: "Don't like the video? Animate again — each run creates a new version." }, { n: 4, title: "Select versions", body: "The bottom strip shows all versions. Click one to make it active." }, ].map((step) => (
{step.n}

{step.title}

{step.body}

))}

API: POST /api/projects/{'{projectId}'}/scenes/{'{sceneId}'}/shots/{'{shotId}'}/animate — returns prompt_id for tracking. Backend uses Wan 2.2 I2V on AMD MI300X.

); } interface ShotCardProps { shot: Shot; phase: ProjectPhase; selected: boolean; saving: boolean; onSelect: () => void; onGenerateImage: () => void; onAnimate: () => void; onDeleteShot: () => void; } function ShotCard({ shot, phase, selected, saving, onSelect, onGenerateImage, onAnimate, onDeleteShot }: ShotCardProps) { const imageUrl = mediaUrl(shot.image_file); const videoUrl = mediaUrl(shot.video_file); const showAnimate = !!imageUrl; // Only show video if there's no newer image version (re-image invalidates old video) const showVideo = !!videoUrl && shot.status !== 'image_ready'; const rendering = shot.status === 'rendering_image' || shot.status === 'animating' || saving; return (
{videoUrl ? (

{shot.description || no description}

{rendering ? (
{shot.status === 'animating' ? 'Animating…' : 'Generating…'}
) : phase === "outline" || !imageUrl ? ( <> ) : ( <> )}
); } interface ShotEditorProps { shot: Shot; phase: ProjectPhase; saving: boolean; onPatch: (patch: Partial) => void; onGenerate: () => void; onAnimate: () => void; } function ShotEditor({ shot, phase, saving, onPatch, onGenerate, onAnimate }: ShotEditorProps) { const [draft, setDraft] = useState({ subtitle: shot.subtitle || "", description: shot.description || "", image_prompt: shot.image_prompt || "", motion_prompt: shot.motion_prompt || "", }); useEffect(() => { setDraft({ subtitle: shot.subtitle || "", description: shot.description || "", image_prompt: shot.image_prompt || "", motion_prompt: shot.motion_prompt || "", }); }, [shot.id]); // eslint-disable-line react-hooks/exhaustive-deps const dirty = draft.subtitle !== (shot.subtitle || "") || draft.description !== (shot.description || "") || draft.image_prompt !== (shot.image_prompt || "") || draft.motion_prompt !== (shot.motion_prompt || ""); return (