Repository Public
1import Link from "next/link";2import { AutoRefresh } from "@/components/studio/AutoRefresh";3import { A, Meter, Notice, PageHead, Panel, Status, ago, usd, when } from "@/components/studio/ui";4import { can, requireRole } from "@/lib/auth";5import { getPolicy } from "@/lib/budget";6import { sql } from "@/lib/db";7import { env } from "@/lib/env";8import { storage } from "@/lib/storage";9import { directorAvailable } from "@/pipeline/director";10import { startDirector, stopDirector } from "../../actions";1112export const metadata = { title: "Director" };13type Entry = { t: string; kind: string; text: string };14interface Run {15 id: string; project_id: string; title: string; project_status: string; idea: string; budget_usd: number; status: string; log: Entry[]; agent_model: string | null;16 agent_cost_usd: number | null; turns: number | null; report: string | null; error: string | null; created_at: Date; started_at: Date | null; finished_at: Date | null; media_usd: number;17}18const ICON: Record<string, string> = { say: "💬", tool: "▶", note: "●", error: "!", system: "·" };1920export default async function Director({ searchParams }: { searchParams: Promise<{ ok?: string; err?: string; run?: string }> }) {21 const sp = await searchParams;22 const user = await requireRole("viewer");23 const editor = can(user, "editor");24 const [policy, runs] = await Promise.all([25 getPolicy(),26 sql<Run[]>`27 SELECT r.*, p.title, p.status AS project_status,28 COALESCE((SELECT SUM(usd) FROM cost_entries c WHERE c.project_id = r.project_id AND c.kind <> 'agent'), 0)::float8 AS media_usd29 FROM director_runs r JOIN projects p ON p.id = r.project_id ORDER BY r.created_at DESC LIMIT 20`,30 ]);31 const available = directorAvailable();32 const current = runs.find((r) => r.id === sp.run) ?? runs[0] ?? null;33 const live = runs.some((r) => r.status === "queued" || r.status === "running");34 const frames = current ? await sql<{ sha256: string; ext: string; kind: string; idx: number }[]>`35 SELECT a.sha256, a.ext, 'keyframe' AS kind, s.idx FROM shots s JOIN assets a ON a.id = s.image_asset_id WHERE s.project_id = ${current.project_id} ORDER BY s.idx` : [];36 const [cut] = current ? await sql<{ sha256: string; ext: string; poster: string | null }[]>`37 SELECT a.sha256, a.ext, (SELECT sha256 FROM assets x WHERE x.id = f.poster_asset_id) AS poster FROM film_versions f JOIN assets a ON a.id = f.master_asset_id WHERE f.project_id = ${current.project_id} ORDER BY f.version DESC LIMIT 1` : [];38 const signed = (sha: string, ext: string) => storage().getSignedUrl(sha, ext, 6 * 3600);3940 return (41 <>42 <AutoRefresh seconds={4} active={live} />43 <PageHead title="Director" sub="Give one idea. Claude Code writes the film, designs it, and orchestrates the xAI models: keyframes, cheap motion previews, final 1080p clips, narration, the cut and QA. It looks at every image it makes and fixes what is wrong. It cannot publish: the film ends in your approval queue." />44 <Notice ok={sp.ok} err={sp.err} />45 {!available && <Notice err="Claude Code is not installed or has no API key on this node (expected: /opt/homebrew/bin/claude and ~/.config/clip/anthropic-api-key)." />}4647 <div className="grid gap-5 lg:grid-cols-[1fr_1.4fr]">48 <div className="space-y-5">49 {editor && (50 <Panel title="New film from an idea">51 <form action={startDirector} className="space-y-4">52 <div><label htmlFor="idea" className="label">Idea</label><textarea id="idea" name="idea" rows={5} required minLength={8} maxLength={2000} className="field mt-1" placeholder="A tiny octopus who works the night shift at a lighthouse and is afraid of the dark" /></div>53 <div className="grid grid-cols-3 gap-3">54 <div><label htmlFor="language" className="label">Language</label><select id="language" name="language" className="field mt-1"><option value="en">English</option><option value="fr">Français</option><option value="bilingual">FR + EN</option></select></div>55 <div><label htmlFor="shots" className="label">Shots</label><select id="shots" name="shots" defaultValue="5" className="field mt-1">{[3, 4, 5, 6].map((n) => <option key={n}>{n}</option>)}</select></div>56 <div><label htmlFor="budget" className="label">Budget $</label><input id="budget" name="budget" type="number" min={3} max={Number(policy.per_film_usd)} step={1} defaultValue={Math.min(15, Number(policy.per_film_usd))} className="field mt-1" /></div>57 </div>58 <p className="text-xs text-[var(--color-faint)]">Typical 5-shot film at 1080p: about $9–12 of xAI media plus $1–3 of Claude. The tools refuse to pass this budget, the per-shot ceiling ({usd(policy.per_shot_usd)}) or the per-film ceiling ({usd(policy.per_film_usd)}).</p>59 <button className="btn btn-primary w-full" disabled={!available || policy.kill_switch}>{policy.kill_switch ? "Kill switch is on" : "Direct this film"}</button>60 </form>61 </Panel>62 )}63 <Panel title="Runs">64 {runs.length ? <ul className="space-y-2">{runs.map((r) => (65 <li key={r.id}><Link href={`/studio/director?run=${r.id}`} scroll={false} className={`block rounded-2xl border p-3 text-sm ${current?.id === r.id ? "border-[var(--color-lamp)]" : "border-[var(--color-line-soft)]"}`}>66 <span className="flex items-center justify-between gap-2"><span className="truncate font-medium">{r.title}</span><Status value={r.status} /></span>67 <span className="mt-1 block text-xs text-[var(--color-faint)]">{ago(r.created_at)} · media {usd(r.media_usd)}{r.agent_cost_usd !== null ? ` · Claude ${usd(r.agent_cost_usd)}` : ""} · {r.project_status.replace(/_/g, " ")}</span>68 </Link></li>))}</ul> : <p className="text-sm text-[var(--color-faint)]">No run yet.</p>}69 </Panel>70 </div>7172 {current ? (73 <div className="space-y-5">74 <Panel title="Current run" action={<A href={`/studio/projects/${current.project_id}`}>Open the project</A>}>75 <div className="flex flex-wrap items-start justify-between gap-3">76 <div className="min-w-0"><p className="display text-3xl">{current.title}</p><p className="mt-1 text-sm text-[var(--color-muted)]">{current.idea}</p></div>77 <Status value={current.status} />78 </div>79 <div className="mt-4"><Meter label="Media budget for this run" spent={current.media_usd} cap={Number(current.budget_usd)} /></div>80 <p className="mt-2 text-xs text-[var(--color-faint)]">81 {current.started_at ? `Started ${when(current.started_at)}` : "Queued"}{current.finished_at ? ` · finished ${when(current.finished_at)}` : ""}{current.turns ? ` · ${current.turns} turns` : ""}82 {current.agent_cost_usd !== null ? ` · Claude ${usd(current.agent_cost_usd)}` : ""}{current.agent_model ? ` · ${current.agent_model}` : ""} · media by {env.imageModel}, {env.videoDraftModel} (previews), {env.videoModel} (finals)83 </p>84 {current.error && <p className="mt-3 break-words text-sm text-[var(--color-bad)]">{current.error}</p>}85 {editor && (current.status === "running" || current.status === "queued") && <form action={stopDirector} className="mt-3"><input type="hidden" name="run" value={current.id} /><button className="btn btn-danger btn-sm">Stop this run</button></form>}86 {(frames.length > 0 || cut) && (87 <div className="mt-4 flex gap-2 overflow-x-auto pb-1">88 {cut && <video src={signed(cut.sha256, cut.ext)} poster={cut.poster ? signed(cut.poster, "jpg") : undefined} controls playsInline preload="none" className="h-56 w-auto rounded-xl bg-black" aria-label="Current cut" />}89 {frames.map((f) => <img key={f.idx} src={signed(f.sha256, f.ext)} alt={`Keyframe of shot ${f.idx + 1}`} className="h-56 w-auto rounded-xl" loading="lazy" />)}90 </div>91 )}92 </Panel>93 {current.report && <Panel title="Director's report"><p className="whitespace-pre-wrap text-sm leading-relaxed text-[var(--color-muted)]">{current.report}</p></Panel>}94 <Panel title={`Live log (${current.log.length})`}>95 {current.log.length ? (96 <ol className="max-h-[32rem] space-y-1.5 overflow-y-auto pr-1 text-sm">97 {[...current.log].reverse().map((e, i) => (98 <li key={i} className="grid grid-cols-[1.4rem_1fr] gap-1">99 <span aria-hidden="true" className={e.kind === "error" ? "text-[var(--color-bad)]" : e.kind === "note" ? "text-[var(--color-accent)]" : "text-[var(--color-faint)]"}>{ICON[e.kind] ?? "·"}</span>100 <span className={`min-w-0 break-words ${e.kind === "tool" ? "mono text-[var(--color-muted)]" : e.kind === "error" ? "text-[var(--color-bad)]" : e.kind === "note" ? "font-medium" : "text-[var(--color-muted)]"}`}>{e.text}<span className="ml-2 text-[0.65rem] text-[var(--color-faint)]">{new Date(e.t).toLocaleTimeString("en-CA", { timeZone: "America/Toronto", hour12: false })}</span></span>101 </li>))}102 </ol>103 ) : <p className="text-sm text-[var(--color-faint)]">Waiting for the worker to start Claude Code…</p>}104 </Panel>105 </div>106 ) : <div className="card grid min-h-64 place-items-center p-10 text-center text-[var(--color-faint)]">Describe an idea on the left: the director takes it from there.</div>}107 </div>108 </>109 );110}111