SPB Git

Repository Public

director.ts 15.4 KB 195 lines typescript

1import { spawn } from "node:child_process";2import { existsSync, readFileSync } from "node:fs";3import { chmod, mkdir, writeFile } from "node:fs/promises";4import os from "node:os";5import path from "node:path";6import { sql, j } from "../lib/db";7import { env } from "../lib/env";8import { log, redact } from "../lib/log";9import { TAGS } from "../lib/taxonomy";10import { VOICES } from "../lib/contracts";1112/**13 * The Claude Code director: one idea in, one orchestrated film out.14 * The worker starts Claude Code headless in an empty working directory. Its only tools are `./clip` (the studio's15 * own commands, which enforce budgets and record every cost), Read (to look at the frames it produced) and Write16 * (to write its plan and prompts). It cannot publish: the run ends at QA and a human approves.17 */18const MAX_MINUTES = 60;19const KEY_FILES = [path.join(os.homedir(), ".config/clip/anthropic-api-key")];2021function anthropicKey(): string | null {22  if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;23  for (const f of KEY_FILES) if (existsSync(f)) return readFileSync(f, "utf8").trim();24  return null;25}26export const directorAvailable = () => !!anthropicKey() && ["/opt/homebrew/bin/claude", "/usr/local/bin/claude"].some((p) => existsSync(p));27const claudeBin = () => ["/opt/homebrew/bin/claude", "/usr/local/bin/claude"].find((p) => existsSync(p)) ?? "claude";2829const PLAYBOOK = `You are the director of CLIP, an AI cinema. You receive one idea and you deliver one finished short vertical film (9:16, 25–40 seconds, 4–6 shots), ready for a human to approve. You write it, design it, and orchestrate its production with the tools below. xAI models make the media; you make every creative decision and you check the work with your own eyes.3031# Tools32Use only \`./clip <command>\` (Bash), Read (to look at image files under ./view) and Write (to write files in this directory). Nothing else is available and nothing else is needed.3334./clip status                      project, shots, money spent, prices35./clip plan plan.json              submit the whole film: script, bible and shots (schema below)36./clip keyframe <n>                generate the first frame of shot n (2k). Shot 1 from text; shots 2+ are made by EDITING shot 1, so the character stays identical37./clip keyframe <n> --prompt-file f.txt     same, with a revised image prompt38./clip voice                       synthesise all narration (cheap). Run it before any render: each clip is sized to its line39./clip voice <n> --narration "…" --alt "…"  rewrite one line and its caption adaptation40./clip preview <n>                 CHEAP draft of the shot's motion (draft model, 480p, a few cents). Use it to test the action and camera before paying for the final41./clip preview <n> --prompt-file f.txt      preview with a revised motion prompt42./clip render <n|all>              FINAL clip (master model, 1080p, about $0.25 per second). Expensive: render when the keyframe and preview are right43./clip render <n> --prompt-file f.txt       re-render one shot with a revised motion prompt44./clip factcheck                   informational films only: checks every narration line against sources (web search)45./clip assemble                    cut the film: picture, narration, ducked ambience, captions in French and English46./clip qa                          technical checks + an independent critic. This is the gate47./clip note "…"                    one short progress line for the operator watching the studio4849Run ONE ./clip command per Bash call, with literal arguments: no shell variables, loops, pipes, redirections, cat, tail or background jobs (they are refused). Each call waits for its result.50Every command prints JSON. Image paths in the output are relative to this directory: open them with Read and LOOK at them. An {"error": …} answer tells you what to do next.5152# How to work531. ./clip status. Think about the idea: who is the protagonist, what do they want, what is the payoff? Write plan.json and submit it.542. ./clip keyframe 1, then look at it. This image sets the character and the look for the whole film. If it is not charming, clear and well lit, rewrite the prompt and regenerate it now — never build on a weak first frame.553. Generate the other keyframes and look at each one: same character? different framing from the others? does it show its line?564. ./clip voice.575. ./clip preview each shot and look at the frames. Fix motion prompts that do not work. Previews are cheap; use them.586. ./clip render all, then look at the final frames. Re-render only what a viewer would notice (at most twice per shot).597. For an informational film, ./clip factcheck and fix any line that is not supported.608. ./clip assemble, then ./clip qa. If QA fails on something you can fix inside the budget, fix it and run assemble and qa again. Otherwise stop.619. Finish with a short report: title, what the film is, what you checked, what it cost, and anything the human approver should look at. You cannot publish and must not try.6263Leave a ./clip note at each step so the operator can follow.6465# Creative bar66- A protagonist the viewer cares about within two seconds: an invented character (animal, creature, robot, stylised person, or an object with personality) who WANTS something. Charm, wit and feeling. Every shot has visible action. The last shot is a payoff worth rewatching.67- Narration: one line per shot, 10–20 words, speakable in 4–7 seconds, concrete and warm. Characters do not speak on screen.68- Never a real person, a brand, a logo, a copyrighted character, or readable on-screen text. Fiction is fiction; facts must be true.6970# Prompt craft (this decides the quality)71Image prompt for SHOT 1 — brief it like a photograph, in this order: (a) subject first: who, doing what, supported by what, where; (b) details and focal point: materials, counts, expression, camera angle; (c) art direction: two or three named props and colour fields; (d) light: source, direction, hardness, and an exposure bright enough to read the subject; (e) colours of subject and background as separate fields; (f) composition: "vertical frame", crop, what stays sharp. Describe what IS there; no lists of things to avoid. Night scenes are lit (lanterns, moon, windows).72Image prompt for SHOTS 2+ — an edit of shot 1: "A new scene with the same <protagonist> and <signature accessory>, seen from a different angle: <new framing and place>. <What they are doing now and what else is in frame>. <Light and colour>. Keep the same <protagonist>, <accessory>, colours and film look." Change the framing and the place or action clearly each time (wide, medium, close, low, high).73Motion prompt — the action as two or three beats in order, then ONE motivated camera move (slow push-in, tracking, crane up, orbit, handheld follow, rack focus), then three or four specific sounds heard in the scene. End with "No speech." Generative video is good at expressive characters, animals, weather, steam, water, fabric and light; weak at crowds, fine finger work, fast fights and text.7475# plan.json76{77  "titleEn": "", "titleFr": "", "synopsisEn": "two sentences", "synopsisFr": "deux phrases, français naturel", "takeawayEn": "", "takeawayFr": "",78  "language": "en | fr | bilingual", "kind": "fiction | factual | experimental",79  "voiceId": one of ${VOICES.join(", ")} (male: altair atlas helios kepler leo orion sal zenith; female: ara aurora carina celeste eve iris luna ursa),80  "voiceDirection": "tone and pace",81  "tags": [slugs from: ${TAGS.map((t) => t.slug).join(", ")}],82  "bible": { "palette": "", "lighting": "", "lens": "", "world": "", "characters": [{ "name": "", "description": "fixed visual description" }], "anchors": ["recurring props"], "avoid": "" },83  "shots": [ { "title": "", "purpose": "hook | setup | tension | turn | payoff", "lang": "en | fr", "narration": "spoken line", "narrationAlt": "natural adaptation in the other language (FR⇄EN) for captions", "camera": "", "action": "", "transition": "cut | fade", "imagePrompt": "", "videoPrompt": "" } ]84}`;8586type Entry = { t: string; kind: "say" | "tool" | "result" | "note" | "error" | "system"; text: string };8788async function append(runId: string, entries: Entry[]) {89  if (!entries.length) return;90  await sql`UPDATE director_runs SET log = log || ${j(entries)} WHERE id = ${runId}`;91}92const now = () => new Date().toISOString();93const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);9495export async function runDirector(runId: string): Promise<void> {96  const [run] = await sql<{ id: string; project_id: string; idea: string; budget_usd: number; status: string }[]>`SELECT * FROM director_runs WHERE id = ${runId}`;97  if (!run || run.status !== "queued") return;98  const key = anthropicKey();99  if (!key || !directorAvailable()) {100    await sql`UPDATE director_runs SET status = 'failed', error = 'Claude Code is not installed or has no API key on this node', finished_at = now() WHERE id = ${runId}`;101    return;102  }103  const appDir = process.cwd();104  const workdir = path.join(env.assetRoot, "director", runId);105  await mkdir(path.join(workdir, "view"), { recursive: true });106  // The wrapper pins the project and the run: the director cannot address another project.107  const wrapper = `#!/bin/bash\ncd ${JSON.stringify(appDir)} && CLIP_QUIET=1 CLIP_WORKDIR=${JSON.stringify(workdir)} exec node_modules/.bin/tsx scripts/director-tool.ts --project ${run.project_id} --run ${runId} "$@"\n`;108  await writeFile(path.join(workdir, "clip"), wrapper);109  await chmod(path.join(workdir, "clip"), 0o755);110111  const [p] = await sql<{ language: string; brief: { shots?: number } }[]>`SELECT language, brief FROM projects WHERE id = ${run.project_id}`;112  const prompt = `Idea from the studio owner:\n\n${run.idea}\n\nSpoken language: ${p.language === "fr" ? "French" : p.language === "bilingual" ? "French and English alternating" : "English"}. Shots: about ${p.brief.shots ?? 5}. Budget for this run: $${Number(run.budget_usd).toFixed(2)} (the tools refuse to exceed it).\n\nDirect this film now, from plan to QA.`;113  const settings = { permissions: { deny: ["Read(~/apps/**)", "Read(~/.config/**)", "Read(~/.ssh/**)", "Read(**/.env)", "Read(**/.env.*)", "WebFetch", "WebSearch"] } };114  const args = [115    "-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", "150",116    "--allowedTools", "Bash(./clip:*)", "Read", "Write",117    "--append-system-prompt", PLAYBOOK, "--settings", JSON.stringify(settings),118    ...(process.env.CLIP_DIRECTOR_MODEL ? ["--model", process.env.CLIP_DIRECTOR_MODEL] : []),119  ];120  await sql`UPDATE director_runs SET status = 'running', started_at = now() WHERE id = ${runId}`;121  await append(runId, [{ t: now(), kind: "system", text: "Claude Code started" }]);122  log("info", "director.start", { runId, project: run.project_id });123124  const child = spawn(claudeBin(), args, {125    cwd: workdir,126    env: { NODE_ENV: "production", PATH: `/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin`, HOME: os.homedir(), ANTHROPIC_API_KEY: key, LANG: "en_US.UTF-8", TERM: "dumb", DISABLE_AUTOUPDATER: "1" } as NodeJS.ProcessEnv,127    stdio: ["ignore", "pipe", "pipe"],128  });129  let buffer = "", stderr = "", stopped = false;130  let final: { cost?: number; turns?: number; report?: string; isError?: boolean; model?: string; session?: string } = {};131  let queue = Promise.resolve();132  const push = (entries: Entry[]) => { queue = queue.then(() => append(runId, entries)).catch(() => {}); };133134  const handle = (line: string) => {135    let ev: Record<string, unknown>;136    try { ev = JSON.parse(line); } catch { return; }137    if (ev.type === "system" && ev.subtype === "init") { final.model = String(ev.model ?? ""); final.session = String(ev.session_id ?? ""); return; }138    if (ev.type === "assistant") {139      const content = ((ev.message as { content?: unknown[] })?.content ?? []) as { type: string; text?: string; name?: string; input?: Record<string, unknown> }[];140      const out: Entry[] = [];141      for (const c of content) {142        if (c.type === "text" && c.text?.trim()) out.push({ t: now(), kind: "say", text: clip(redact(c.text.trim()), 1500) });143        if (c.type === "tool_use") {144          const what = c.name === "Bash" ? String(c.input?.command ?? "") : c.name === "Read" ? `look at ${path.basename(String(c.input?.file_path ?? ""))}` : c.name === "Write" ? `write ${path.basename(String(c.input?.file_path ?? ""))}` : String(c.name);145          if (!/^\.\/clip note/.test(what)) out.push({ t: now(), kind: "tool", text: clip(what, 300) });146        }147      }148      push(out);149      return;150    }151    if (ev.type === "user") {152      const content = ((ev.message as { content?: unknown[] })?.content ?? []) as { type: string; content?: unknown; is_error?: boolean }[];153      for (const c of content) {154        if (c.type !== "tool_result") continue;155        const text = typeof c.content === "string" ? c.content : Array.isArray(c.content) ? (c.content as { type: string; text?: string }[]).filter((x) => x.type === "text").map((x) => x.text).join(" ") : "";156        const err = /^\s*\{\s*"error"\s*:/.test(text) || c.is_error;157        if (err) push([{ t: now(), kind: "error", text: clip(redact(text.replace(/\s+/g, " ")), 400) }]);158      }159      return;160    }161    if (ev.type === "result") {162      final = { ...final, cost: Number(ev.total_cost_usd ?? 0), turns: Number(ev.num_turns ?? 0), report: typeof ev.result === "string" ? ev.result : "", isError: !!ev.is_error };163    }164  };165  child.stdout.on("data", (d: Buffer) => {166    buffer += d.toString();167    let i: number;168    while ((i = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, i).trim(); buffer = buffer.slice(i + 1); if (line) handle(line); }169  });170  child.stderr.on("data", (d: Buffer) => { stderr = (stderr + d.toString()).slice(-2000); });171172  const deadline = Date.now() + MAX_MINUTES * 60_000;173  const watch = setInterval(async () => {174    const [r] = await sql<{ stop_requested: boolean }[]>`SELECT stop_requested FROM director_runs WHERE id = ${runId}`.catch(() => []);175    const [pol] = await sql<{ kill_switch: boolean }[]>`SELECT kill_switch FROM budget_policy WHERE id = 1`.catch(() => []);176    if (r?.stop_requested || pol?.kill_switch || Date.now() > deadline) { stopped = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 8000); }177  }, 10_000);178179  const code: number | null = await new Promise((resolve) => child.on("close", resolve));180  clearInterval(watch);181  if (buffer.trim()) handle(buffer.trim());182  await queue;183184  const ok = code === 0 && !final.isError && !stopped;185  const error = ok ? null : stopped ? (Date.now() > deadline ? `Stopped after ${MAX_MINUTES} minutes` : "Stopped by the operator or the kill switch") : redact(final.report || stderr || `Claude Code exited with code ${code}`).slice(0, 800);186  await sql`UPDATE director_runs SET status = ${ok ? "done" : stopped ? "stopped" : "failed"}, finished_at = now(), agent_cost_usd = ${final.cost ?? null}, turns = ${final.turns ?? null},187            report = ${final.report ? redact(final.report).slice(0, 6000) : null}, error = ${error}, agent_model = ${final.model ?? null}, session_id = ${final.session ?? null} WHERE id = ${runId}`;188  // The director's own cost belongs to the film's ledger too.189  if (final.cost && final.cost > 0) {190    await sql`INSERT INTO cost_entries (project_id, kind, model, usd, source, failed) VALUES (${run.project_id}, 'agent', ${final.model || "claude-code"}, ${final.cost}, 'claude-code', ${!ok})`;191  }192  await sql`UPDATE projects SET running_stage = NULL, updated_at = now() WHERE id = ${run.project_id}`;193  log(ok ? "info" : "error", "director.end", { runId, ok, turns: final.turns, usd: final.cost, error });194}195
Files main
Menu

Theme