SPB Git

Repository Public

page.tsx 9.5 KB 111 lines tsx

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
Files main
Menu

Theme