OR Key
drop another .md file to compare - side-by-side diff against remotion

remotion

Creates videos automatically from a template.
personal 2 files

What it does for you

Creates videos automatically from a template.

What it produces

A recent result, so you can see the kind of work it returns.

loading…

How to get it

These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

actorIntent dispatch router (this skill - the three-way…
auditorArtifact verifier
eval modeauto
categoryMedia
stages4
dependsffmpeg, image

what's inside - the parts that make up a skill 3/4 present

A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.

The skill
state/skills/remotion/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/remotion.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/remotion/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/remotion/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

how it's graded - what counts as a good run 6 criteria · 6 deterministic

Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.

name
kind
check
gate_checks_ffmpeg
deterministic
Render request gate runs `which ffmpeg` and returns non-empty path. If empty, gate fails with install message.
list_returns_30day_window
deterministic
List request reads `state/log/media/clips.ndjson` and filters to clips created in past 30 days. Returns array shape: {clips: [{id, name, duration_s, dimensions, created_at, thumbnail_url, format}], total: N}.
edit_stages_without_writing
deterministic
Edit request returns staged mutation object without writing to disk. Response shape: {edit_staged: true, clip_id, mutation, preview}. File system unchanged.
render_dispatch_async
deterministic
Render request invokes ffmpeg/Remotion asynchronously, captures PID immediately, logs to `state/log/remotion-renders.ndjson`, returns {rendering: true, render_pid, output_path, eta_seconds} immediately without waiting.
render_score_timing
deterministic
Render requests score 0.5 at dispatch; list/edit requests score 1.0 immediately. Render upgrades to 1.0 only when artifact auditor verifies completed.
intent_parsing_graceful
deterministic
Unrecognizable intent gates gracefully (no throw). Returns clear help text with example intents.

how it runs - the shared frame every skill uses 4/5 present

Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.

makes the work The worker
present
Intent dispatch router (this skill - the three-way… the worker
Does the actual work. Whatever it produces is what gets checked next.
checks the work The reviewer
present
Artifact verifier the checker
A separate checker grades the work, so the part that made it can't approve its own work.
frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson auto runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
  1. List: Query state/log/media/clips.ndjson, filter past 30d, return {clips: [{id, name, duration_s, dimensions, created_at, thumbnail_url, format}], total: N}. Empty file → {clips: [], total: 0}.
  2. Edit: Stage mutation without writing to disk. Return {edit_staged: true, clip_id, mutation, preview}.
  3. Render: Gate on which ffmpeg (hard-fail if missing); dispatch async to Remotion CLI; capture PID; log {ts, pid, entry_file, output_path} to state/log/remotion-renders.ndjson; return {rendering: true, render_pid, output_path, eta_seconds} immediately.
  4. Never wait synchronously on render. Return PID + eta immediately; artifact auditor polls for completion.
  5. Gate gracefully on missing ffmpeg/clip/scenes. Return help text with options. Never throw.
  6. Eval scoring: render=0.5 (dispatch pending verification); list/edit=1.0 (immediate, no side effects). Upgrade render→1.0 only when auditor verifies completion in clips file.

what it has learned - fixes written back in over time sample

When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.

  1. Loading feedback rows…

how the work flows- who makes it, who checks it

inputs ffmpegimage
actor Intent dispatch router (this skill - the three-way…
1 control
Scope - no side effects
Parse the intent and determine request type:
what this step does
Parse the intent and determine request type: - "show my videos" / "open remotion" / "my videos" → list recent renders from state/log/media/clips.ndjson (past 30 days) - "edit this clip" / "swap scene 2 to use warmer palette" → stage a mutation without writing files - "render a 10s opener with my logo" / "create a 30s reel" → dispatch to render pipeline (ffmpeg + Remotion) Return media list metadata, active clip properties, available scenes, and FollowUpBlock next moves. No fi
auditor Artifact verifier
2 data
Log + eval
```typescript
what this step does
Render requests score 0.5 at dispatch (render started, not yet verified). List/edit requests score 1.0 immediately (no side effects, just data return). Upgrade render to 1.0 only when the artifact auditor confirms the render completed and landed in state/log/media/clips.ndjson.

SKILL.md- the skill, written out in plain English

remotion

The inline video composition surface. Takes a user request to show, edit, or render Remotion videos, surfaces them in the right-rail canvas with clip preview, frame scrubber, and scene-swap affordances. Powered by Remotion CLI for preview/render and live-composed OpenUI surfaces persisted as artifact lang_body when reusable.

This skill exists because Robert's complaint is "Where is the Remotion? Where is the video editing? Where is the videos online on the right side?" The skill dispatches user intents to one of three paths: (1) list recent renders from the media queue, (2) edit a clip by swapping scenes or adjusting properties, (3) render a new video via Remotion CLI. The snappy-chat side (tasks #18/#20) will ship VideoComposition and RemotionStudio defineComponents to replace the OpenUI Lang primitives.

Steps

1. Scope - no side effects

Parse the intent and determine request type:

  • "show my videos" / "open remotion" / "my videos" → list recent renders from state/log/media/clips.ndjson (past 30 days)
  • "edit this clip" / "swap scene 2 to use warmer palette" → stage a mutation without writing files
  • "render a 10s opener with my logo" / "create a 30s reel" → dispatch to render pipeline (ffmpeg + Remotion)

Return media list metadata, active clip properties, available scenes, and FollowUpBlock next moves. No files modified.

2. Gate

Hard-fail if:

  • Render request but ffmpeg is missing (checked via which ffmpeg); return install instructions
  • Edit request references a clip ID not in state/log/media/clips.ndjson; return list of available clips
  • Scene-swap request but clip metadata has no defined scenes; return available scene options
  • Intent is unrecognizable; return help text with example intents

Soft-skip gracefully; never throw exceptions. The UI frames the gated state as a chooser.

3. Act (only if gate passes)

List request: Query state/log/media/clips.ndjson for clips created in the past 30 days. Return array of objects: {id, name, duration_s, dimensions, created_at, thumbnail_url, format}. If file missing or empty, return {clips: [], total: 0} and the surface renders the empty state.

Edit request: Stage the mutation in a response object (do NOT write files). Return the staged clip metadata so the user sees a preview. Example: {edit_staged: true, clip_id: "abc123", mutation: {scene: 2, palette: "warm"}, preview: {...}}. The operator's actual Apply click gates the real write (handled by snappy-chat's mutation tool).

Render request - the ONLY correct path:

Call the render_video Mutation. Do NOT shell out to Bash with npx tsx state/lib/remotion.ts render ... or npx remotion render .... Bash-direct returns a raw /tmp/*.mp4 filesystem path that the cockpit's file:// origin cannot fetch - the video element renders a black frame with a play icon and the user sees a broken player. The render_video Mutation copies the mp4 into the artifact store and returns a served URL.

Two paths, decided by whether the user supplied the content inline:

  1. Content already in the intent (e.g. "render a short QuoteCard video saying Brubaker doctrine ships visible proof"). Call the render_video Mutation IMMEDIATELY with derived props:
   render_video({composition: "QuoteCard", props: {quote: "<extracted text>", author: "<derived or null>"}})

Do NOT stage a form. The user already gave you the values; staging a form is a fail.

The Mutation returns {ok:true, artifactId, url:"/artifacts/<artifactId>/file", bytes, composition, durationMs}. The url field is the head-screen-served URL the cockpit can actually load.

After the Mutation returns, compose a confirmation surface that puts the returned url into VideoPlayer:

   url = "/artifacts/<artifactId>/file"
   root = Card([
     CardHeader("QuoteCard video", "Rendered successfully"),
     VideoPlayer(url, "<the quote text>")
   ])

DO NOT put /tmp/snappy-remotion-*.mp4 in the VideoPlayer src. That is the upstream Remotion temp path, not a fetchable URL. If your Mutation result does not have a /artifacts/<id>/file style url, the render failed - surface the failure instead of pretending.

Alternatively, do not compose a confirmation surface at all. The new video artifact auto-mounts in the right rail because the cockpit detects kind:"video" + shape_name:"VideoPlayer" artifacts and routes them through renderShapeFromArgs("VideoPlayer", parsedArgs). A one-line prose confirmation in chat ("Rendered. Playing on the right.") is enough.

  1. Content missing (e.g. "make me a video", "render something"). Only then stage a small form via compose_inline so the user can supply quote/author/composition. The form is a fallback when the model has no information to render.

Hard rules, do not violate:

  • The ONLY correct render entry point is render_video (Mutation).
  • The VideoPlayer src MUST be the url from the Mutation response, not a /tmp path.
  • "render a video saying X" with content in the prompt must produce a playing MP4, not a fillable form.

Robert directive 2026-05-22: previous turns staged a form and then bash-direct-rendered an unloadable mp4. Both failure modes are blocked by these rules.

4. Log + eval

import { score } from "../../lib/eval.ts";
score("remotion", run_id, {
  score: intent.includes("render") ? 0.5 : 1.0,  // render=0.5 at dispatch; edit/list=1.0
  intent_type: "render" | "edit" | "list",
  clip_count: clips?.length ?? 0,
  primary_issue: <null | gate-fail reason>,
});

Render requests score 0.5 at dispatch (render started, not yet verified). List/edit requests score 1.0 immediately (no side effects, just data return). Upgrade render to 1.0 only when the artifact auditor confirms the render completed and landed in state/log/media/clips.ndjson.

Eval

Actor: intent dispatch router (this skill - the three-way brancher). Auditor: artifact verifier reads state/log/media/clips.ndjson and checks for the newly rendered clip (auditor must be independent code path).

Score convention:

OutcomeScore
List or edit request dispatched + metadata returned1.0
Render request accepted + PID logged + immediate return0.5
Render completed and artifact verified in clips file1.0
Gate failed (ffmpeg missing, clip not found, no scenes)0.0
Intent parsing failed or intent unrecognizable0.0

Dry-run: not applicable. List/edit requests have no side effects. Render requests only via explicit "render" verb in intent.

Hard rules

  • Never mutate a clip file directly. Stage edits and return a preview for operator approval.
  • Never dispatch a render without checking which ffmpeg first.
  • Always log the render PID to state/log/remotion-renders.ndjson before eval row.
  • List requests default to past 30 days; older clips are archived.
  • Do not assume scene definitions exist - return available scenes as an option list if requested.
  • Always dispatch renders async; never wait synchronously in the skill.

Auth

No external auth. Remotion CLI runs locally via state/lib/ffmpeg.ts helpers. Media metadata in state/log/media/clips.ndjson (internal queue). Render outputs to ~/projects/snappy-os/state/log/media/renders/.

Missing ffmpeg: gate fails with install message pointing to brew install ffmpeg (or system package manager).

Gotchas

  • state/log/media/clips.ndjson may be stale if a render is in progress - this skill reads point-in-time snapshot; cache invalidation is handled by ffmpeg skill
  • Remotion scene definitions are static (authoring-time); you cannot add scenes dynamically, only swap between defined ones
  • A long render (5min+) may block if caller waits synchronously - always return immediately with PID and eta
  • Render output paths contain special chars (timestamps, scene names) - encode before passing to shell (encodeURIComponent)

Rubric

criteria:
  - name: gate_checks_ffmpeg
    kind: deterministic
    check: "Render request gate runs `which ffmpeg` and returns non-empty path. If empty, gate fails with install message."
  - name: list_returns_30day_window
    kind: deterministic
    check: "List request reads `state/log/media/clips.ndjson` and filters to clips created in past 30 days. Returns array shape: {clips: [{id, name, duration_s, dimensions, created_at, thumbnail_url, format}], total: N}."
  - name: edit_stages_without_writing
    kind: deterministic
    check: "Edit request returns staged mutation object without writing to disk. Response shape: {edit_staged: true, clip_id, mutation, preview}. File system unchanged."
  - name: render_dispatch_async
    kind: deterministic
    check: "Render request invokes ffmpeg/Remotion asynchronously, captures PID immediately, logs to `state/log/remotion-renders.ndjson`, returns {rendering: true, render_pid, output_path, eta_seconds} immediately without waiting."
  - name: render_score_timing
    kind: deterministic
    check: "Render requests score 0.5 at dispatch; list/edit requests score 1.0 immediately. Render upgrades to 1.0 only when artifact auditor verifies completed."
  - name: intent_parsing_graceful
    kind: deterministic
    check: "Unrecognizable intent gates gracefully (no throw). Returns clear help text with example intents."

AGENTS.md- what the AI loads when this skill comes up

remotion - loader

Per-turn rules for the remotion skill. Reference: state/skills/remotion/SKILL.md.

Critical Rules

  • List: Query state/log/media/clips.ndjson, filter past 30d, return {clips: [{id, name, duration_s, dimensions, created_at, thumbnail_url, format}], total: N}. Empty file → {clips: [], total: 0}.
  • Edit: Stage mutation without writing to disk. Return {edit_staged: true, clip_id, mutation, preview}.
  • Render: Gate on which ffmpeg (hard-fail if missing); dispatch async to Remotion CLI; capture PID; log {ts, pid, entry_file, output_path} to state/log/remotion-renders.ndjson; return {rendering: true, render_pid, output_path, eta_seconds} immediately.
  • Never wait synchronously on render. Return PID + eta immediately; artifact auditor polls for completion.
  • Gate gracefully on missing ffmpeg/clip/scenes. Return help text with options. Never throw.
  • Eval scoring: render=0.5 (dispatch pending verification); list/edit=1.0 (immediate, no side effects). Upgrade render→1.0 only when auditor verifies completion in clips file.

Commands

verbinvokeinputoutput
listPOST /skill/remotion/list{intent: string}json
editPOST /skill/remotion/edit{intent: string, clip_id: string}json
renderPOST /skill/remotion/render{intent: string, duration_s?: number, dimensions?: string}json
  • list: Query media queue. Routes: "show my videos", "my videos", "open remotion", "list renders", "list compositions".
  • edit: Stage clip mutation (scene swap, palette, properties). Routes: "edit clip", "swap scene", "change palette".
  • render: Async dispatch to Remotion CLI. Routes: "render 10s video", "create 30s reel", "make video", "generate opener". Returns {rendering: true, render_pid, output_path, eta_seconds}; render completes in background.

Self-Test

  1. [ ] "show my videos" → list, queries clips file, returns {clips, total}?
  2. [ ] Gate checks which ffmpeg before render?
  3. [ ] Edit returns staged object without disk writes?
  4. [ ] Render returns immediately with {rendering: true, ...}?
  5. [ ] Render scores 0.5 (dispatch); list/edit score 1.0?
  6. [ ] Gate failures return help text, never throw?

Known Pitfalls

  • Clips file stale during render: Point-in-time snapshot only; cache invalidation is ffmpeg skill's concern.
  • Scene definitions static: Cannot add dynamically; return available scene list when requested.
  • Never wait synchronously on render: Async dispatch critical; artifact auditor polls later.
  • Encode output paths: Use encodeURIComponent before shell invocation (timestamps, scene names contain special chars).
  • ffmpeg missing: which ffmpeg empty → gate fails; point to brew install ffmpeg.

Live Render Path (working as of 2026-05-16)

Remotion project: ~/projects/snappy-remotion Entry: src/index.ts (registers all compositions via RemotionRoot) Output dir: ~/projects/snappy-os/state/log/media/renders/

Working render command:

cd ~/projects/snappy-remotion && npx remotion render src/index.ts CapabilityDemo \
  ~/projects/snappy-os/state/log/media/renders/capability-demo-$(date +%s).mp4

Available compositions (npx remotion compositions src/index.ts):

  • ImageDrivenDemo - 30fps, 1920x1080, 360 frames (12s). Image-backed scenes. Props: {scenes: [{title, subtitle, imagePath, durationFrames}], title}. Falls back to 4 default scenes when scenes is empty.
  • CapabilityDemo - 30fps, 1920x1080, 720 frames (24s). 7 scenes: title + 5 feature/stat + outro. Scenes configurable via defaultProps.scenes.
  • QuoteCard - 30fps, 1080x1080, 150 frames (5s)
  • SocialClip - 30fps, 1080x1920, 150 frames (5s)
  • CourseIntro - 30fps, 1920x1080, 150 frames (5s)
  • DataViz - 30fps, 1080x1080, 150 frames (5s)
  • BrandedOverlay - 30fps, 1920x1080, 150 frames (5s)
  • TestMultiClip - 30fps, 1080x1080, 600 frames (20s)

CapabilityDemo scene types: title, feature, stat, image, outro. Pass scenes array in defaultProps or via --props flag at render time.

Demo Video Compound Flow

For "make a demo video", "create a promo video", "capabilities video", "promotional video":

  1. Generate images via GenerateImage / generate_image (4 in parallel). Capture the returned artifact or https URLs. Scenes: (1) AI network (2) meeting + documents (3) code + interface (4) snappy brand/logo moment.
  1. If the composition accepts URLs, pass those URLs directly. If the local Remotion composition requires filesystem paths, download each artifact URL to /tmp/demo-scenes/scene-N.png first, then build props JSON with imagePath fields pointing to those local files.
  1. Render with ImageDrivenDemo (not CapabilityDemo):
   cd ~/projects/snappy-remotion && npx remotion render src/index.ts ImageDrivenDemo \
     ~/projects/snappy-os/state/log/media/renders/demo-$(date +%s).mp4 \
     --props '{"scenes":[{"title":"snappy","subtitle":"AI that composes","imagePath":"/tmp/demo-scenes/scene1.png","durationFrames":90},...],"title":"snappy"}'
  1. Return {rendering: true, output_path, eta_seconds: 30} immediately. Never wait synchronously.

Async dispatch pattern: spawn render in background, capture PID, log to state/log/remotion-renders.ndjson, return immediately. Do NOT wait synchronously.

Render log schema: {ts, pid, composition, entry_file, output_path, eta_seconds}

Image-Driven Render Pipeline (NanoBanana 2 - verified 2026-05-16)

Full end-to-end: generate images with GenerateImage / generate_image, pass the artifact URLs as Remotion frame data when supported, or download to /tmp only when the local renderer requires paths.

# 1. Generate images through the harness first:
#    generate_image({prompt: "SCENE DESCRIPTION, 16:9"})
#    Each result returns an artifact or https URL such as /artifacts/<id>/file.
#
# 2. Only if the Remotion composition requires local imagePath values, download:
mkdir -p /tmp/demo-scenes
curl -fsSL "http://127.0.0.1:3147/artifacts/<scene-1-artifact-id>/file" -o /tmp/demo-scenes/scene1.png
curl -fsSL "http://127.0.0.1:3147/artifacts/<scene-2-artifact-id>/file" -o /tmp/demo-scenes/scene2.png
curl -fsSL "http://127.0.0.1:3147/artifacts/<scene-3-artifact-id>/file" -o /tmp/demo-scenes/scene3.png

# 3. Build props JSON and render
cd ~/projects/snappy-remotion
PROPS=$(python3 -c "
import json
scenes = [
  {'title': 'snappy', 'subtitle': 'AI that composes, not just responds', 'imagePath': '/tmp/demo-scenes/scene1.png', 'durationFrames': 90},
  {'title': 'Meeting to LinkedIn to Video', 'subtitle': 'One prompt. Compound response.', 'imagePath': '/tmp/demo-scenes/scene2.png', 'durationFrames': 90},
  {'title': '252 skills.', 'subtitle': 'Always discovering.', 'imagePath': '/tmp/demo-scenes/scene3.png', 'durationFrames': 90},
  {'title': '', 'subtitle': 'snappy-os - built for one', 'imagePath': '/tmp/demo-scenes/scene1.png', 'durationFrames': 60},
]
print(json.dumps({'scenes': scenes}))
")
OUTPUT=~/projects/snappy-os/state/log/media/renders/image-driven-demo-$(date +%s).mp4
npx remotion render src/index.ts ImageDrivenDemo "$OUTPUT" --props "$PROPS"

Notes:

  • imagePath is rendered at 40% opacity as a full-cover background behind the title/subtitle overlay.
  • Prefer artifact URLs from GenerateImage / generate_image; /tmp downloads are an adapter for local-only Remotion renderers.
  • durationFrames defaults to 90 if omitted. Total must not exceed the composition's durationInFrames (360 = 12s at 30fps).
  • --props overrides the React component props but NOT the registered durationInFrames. Keep total scene frames <= 360.
  • Images generate independently. Fan out generate_image calls in parallel when the harness supports it.

<!-- footer-injection-point -->

api.ts- the code it can call

#!/usr/bin/env npx tsx
/**
 * snappy-remotion/api.ts -- Programmatic video generation via Remotion.
 *
 * Generates video from React components: quote cards, social clips, course intros,
 * data visualizations, branded overlays. The Remotion project lives at
 * ~/projects/snappy-remotion and must be scaffolded before first use.
 *
 * Env vars:
 *   SNAPPY_REMOTION_DIR  Path to the Remotion project directory.
 *                        Defaults to $HOME/projects/snappy-remotion.
 *
 * Usage:
 *   npx tsx api.ts scaffold
 *   npx tsx api.ts list
 *   npx tsx api.ts render QuoteCard --props '{"text":"Ship it"}'
 *   npx tsx api.ts preview
 *   npx tsx api.ts add ToolDemo "Animated tool walkthrough"
 *
 * Or import as module:
 *   import { scaffoldProject, listCompositions, render, preview, addComposition } from "./remotion.ts";
 */

import { execSync, spawn } from "child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
import { join, basename, dirname } from "path";
import { fileURLToPath } from "url";
import os from "os";
import { env } from "./env.ts";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const PROJECT_DIR = process.env.SNAPPY_REMOTION_DIR ?? join(os.homedir(), "projects", "snappy-remotion");
const SRC_DIR = join(PROJECT_DIR, "src");
const COMP_DIR = join(SRC_DIR, "compositions");
const LIB_DIR = join(SRC_DIR, "lib");

// Clip ledger that "show my videos" reads. Lives in the snappy-os repo, NOT the
// external remotion project dir. state/lib -> repo root.
const CLIPS_LEDGER = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "state/log/media/clips.ndjson");

/** Snappy brand constants used by all compositions. */
export const BRAND = {
  primary: "#2563EB",
  dark: "#0F172A",
  light: "#F8FAFC",
  accent: "#F59E0B",
  cream: "#F5F0E6",
  ink: "#3d3929",
  cta: "#c96442",
  font: "Inter",
  serifFont: "EB Garamond",
} as const;

export const brand = BRAND;

/** Default render dimensions per format. */
export const FORMATS: Record<string, { width: number; height: number; fps: number; durationInFrames: number }> = {
  "vertical":   { width: 1080, height: 1920, fps: 30, durationInFrames: 150 },
  "square":     { width: 1080, height: 1080, fps: 30, durationInFrames: 150 },
  "landscape":  { width: 1920, height: 1080, fps: 30, durationInFrames: 150 },
  "youtube":    { width: 1280, height: 720,  fps: 30, durationInFrames: 300 },
  "story":      { width: 1080, height: 1920, fps: 30, durationInFrames: 450 },
};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function ensureProject(): void {
  if (!existsSync(join(PROJECT_DIR, "package.json"))) {
    throw new Error(
      `Remotion project not found at ${PROJECT_DIR}. Run \`npx tsx state/lib/remotion.ts scaffold\` first.`,
    );
  }
}

function exec(cmd: string, cwd?: string, timeoutMs?: number): string {
  return execSync(cmd, {
    encoding: "utf-8",
    ...(timeoutMs != null ? { timeout: timeoutMs } : {}),
    cwd: cwd || PROJECT_DIR,
    stdio: ["pipe", "pipe", "pipe"],
  }).trim();
}

// ---------------------------------------------------------------------------
// Template content (used by scaffold)
// ---------------------------------------------------------------------------

const PACKAGE_JSON = JSON.stringify(
  {
    name: "snappy-remotion",
    version: "1.0.0",
    private: true,
    scripts: {
      start: "remotion studio",
      build: "remotion render src/index.ts",
      upgrade: "remotion upgrade",
    },
    dependencies: {
      "@remotion/cli": "^4",
      "@remotion/player": "^4",
      react: "^18",
      "react-dom": "^18",
      remotion: "^4",
    },
    devDependencies: {
      "@types/react": "^18",
      typescript: "^5",
    },
  },
  null,
  2,
);

const TSCONFIG = JSON.stringify(
  {
    compilerOptions: {
      target: "ES2022",
      module: "ES2022",
      moduleResolution: "bundler",
      jsx: "react-jsx",
      strict: true,
      esModuleInterop: true,
      skipLibCheck: true,
      outDir: "dist",
    },
    include: ["src"],
  },
  null,
  2,
);

const REMOTION_CONFIG = `import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("png");
Config.setOverwriteOutput(true);
`;

const BRAND_TS = `// Brand constants for all Remotion compositions.
// Source of truth: snappy-positioning AGENTS.md §8 + snappy-remotion/api.ts BRAND export.

export const brand = {
  primary: "${BRAND.primary}",
  dark: "${BRAND.dark}",
  light: "${BRAND.light}",
  accent: "${BRAND.accent}",
  cream: "${BRAND.cream}",
  ink: "${BRAND.ink}",
  cta: "${BRAND.cta}",
  font: "${BRAND.font}",
  serifFont: "${BRAND.serifFont}",
} as const;
`;

const TRANSITIONS_TS = `import { interpolate, useCurrentFrame, Easing } from "remotion";

/** Fade in over \`frames\` frames, starting at \`delay\`. */
export function useFadeIn(delay = 0, frames = 20): number {
  const frame = useCurrentFrame();
  return interpolate(frame, [delay, delay + frames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.ease),
  });
}

/** Slide in from direction over \`frames\` frames. Returns translateX or translateY px. */
export function useSlideIn(
  direction: "left" | "right" | "up" | "down" = "up",
  delay = 0,
  frames = 25,
  distance = 60,
): number {
  const frame = useCurrentFrame();
  const sign = direction === "right" || direction === "down" ? 1 : -1;
  return interpolate(frame, [delay, delay + frames], [sign * distance, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
}

/** Scale from 0 to 1 over \`frames\` frames. */
export function useScaleIn(delay = 0, frames = 20): number {
  const frame = useCurrentFrame();
  return interpolate(frame, [delay, delay + frames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.back(1.5)),
  });
}
`;

const QUOTE_CARD_TSX = `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type QuoteCardProps = {
  text: string;
  speaker?: string;
  title?: string;
};

export const QuoteCard: React.FC<QuoteCardProps> = ({ text, speaker, title }) => {
  const frame = useCurrentFrame();
  const textOpacity = interpolate(frame, [10, 35], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const speakerOpacity = interpolate(frame, [30, 50], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const quoteY = interpolate(frame, [10, 35], [30, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.ease) });
  const lineWidth = interpolate(frame, [0, 25], [0, 80], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ backgroundColor: brand.cream, justifyContent: "center", alignItems: "center", padding: 80, fontFamily: brand.serifFont }}>
      <div style={{ width: lineWidth, height: 3, backgroundColor: brand.cta, marginBottom: 40 }} />
      <div style={{ opacity: textOpacity, transform: \`translateY(\${quoteY}px)\`, fontSize: 52, lineHeight: 1.4, color: brand.ink, textAlign: "center", maxWidth: 800 }}>
        \\u201c{text}\\u201d
      </div>
      {speaker && (
        <div style={{ opacity: speakerOpacity, marginTop: 40, fontSize: 28, color: brand.ink, letterSpacing: 2, textTransform: "uppercase" }}>
          {speaker}{title ? \` \\u2014 \${title}\` : ""}
        </div>
      )}
    </AbsoluteFill>
  );
};
`;

const SOCIAL_CLIP_TSX = `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type SocialClipProps = {
  text: string;
  subtitle?: string;
  background?: string;
};

export const SocialClip: React.FC<SocialClipProps> = ({ text, subtitle, background }) => {
  const frame = useCurrentFrame();
  const titleOpacity = interpolate(frame, [5, 25], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const titleY = interpolate(frame, [5, 25], [40, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.ease) });
  const subOpacity = interpolate(frame, [20, 40], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ backgroundColor: background || brand.dark, justifyContent: "center", alignItems: "center", padding: 60, fontFamily: brand.font }}>
      <div style={{ opacity: titleOpacity, transform: \`translateY(\${titleY}px)\`, fontSize: 64, fontWeight: 700, color: brand.light, textAlign: "center", maxWidth: 900, lineHeight: 1.3 }}>
        {text}
      </div>
      {subtitle && (
        <div style={{ opacity: subOpacity, marginTop: 30, fontSize: 28, color: brand.accent, textAlign: "center" }}>
          {subtitle}
        </div>
      )}
    </AbsoluteFill>
  );
};
`;

const COURSE_INTRO_TSX = `import { AbsoluteFill, useCurrentFrame, interpolate, Sequence, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type CourseIntroProps = {
  text: string;
  category?: string;
  lessonNumber?: number;
};

export const CourseIntro: React.FC<CourseIntroProps> = ({ text, category, lessonNumber }) => {
  const frame = useCurrentFrame();
  const bgScale = interpolate(frame, [0, 60], [1.05, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const categoryOpacity = interpolate(frame, [10, 25], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const titleOpacity = interpolate(frame, [20, 45], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const titleY = interpolate(frame, [20, 45], [50, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.ease) });
  const lineWidth = interpolate(frame, [15, 40], [0, 120], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ backgroundColor: brand.cream, justifyContent: "center", alignItems: "center", fontFamily: brand.font, transform: \`scale(\${bgScale})\` }}>
      {category && (
        <div style={{ opacity: categoryOpacity, fontSize: 22, letterSpacing: 4, textTransform: "uppercase", color: brand.cta, marginBottom: 20, fontWeight: 600 }}>
          {lessonNumber != null ? \`Lesson \${lessonNumber} · \` : ""}{category}
        </div>
      )}
      <div style={{ width: lineWidth, height: 2, backgroundColor: brand.ink, marginBottom: 30 }} />
      <div style={{ opacity: titleOpacity, transform: \`translateY(\${titleY}px)\`, fontSize: 56, fontWeight: 700, color: brand.ink, textAlign: "center", maxWidth: 800, lineHeight: 1.3, fontFamily: brand.serifFont }}>
        {text}
      </div>
    </AbsoluteFill>
  );
};
`;

const DATA_VIZ_TSX = `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type DataVizProps = {
  text: string;
  value?: string;
  subtitle?: string;
  unit?: string;
};

export const DataViz: React.FC<DataVizProps> = ({ text, value, subtitle, unit }) => {
  const frame = useCurrentFrame();
  const valueOpacity = interpolate(frame, [10, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const valueScale = interpolate(frame, [10, 35], [0.5, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.back(1.3)) });
  const labelOpacity = interpolate(frame, [25, 45], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const subOpacity = interpolate(frame, [35, 55], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ backgroundColor: brand.dark, justifyContent: "center", alignItems: "center", fontFamily: brand.font, padding: 60 }}>
      {value && (
        <div style={{ opacity: valueOpacity, transform: \`scale(\${valueScale})\`, fontSize: 140, fontWeight: 800, color: brand.accent, textAlign: "center" }}>
          {value}{unit && <span style={{ fontSize: 60, color: brand.light }}>{unit}</span>}
        </div>
      )}
      <div style={{ opacity: labelOpacity, marginTop: 20, fontSize: 40, fontWeight: 600, color: brand.light, textAlign: "center", maxWidth: 700 }}>
        {text}
      </div>
      {subtitle && (
        <div style={{ opacity: subOpacity, marginTop: 16, fontSize: 24, color: "#94a3b8", textAlign: "center" }}>
          {subtitle}
        </div>
      )}
    </AbsoluteFill>
  );
};
`;

const BRANDED_OVERLAY_TSX = `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type BrandedOverlayProps = {
  text: string;
  position?: "bottom-left" | "bottom-right" | "top-left" | "top-right";
};

export const BrandedOverlay: React.FC<BrandedOverlayProps> = ({ text, position = "bottom-left" }) => {
  const frame = useCurrentFrame();
  const slideIn = interpolate(frame, [5, 25], [-100, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.ease) });
  const opacity = interpolate(frame, [5, 25], [0, 0.95], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  const isBottom = position.startsWith("bottom");
  const isRight = position.endsWith("right");

  return (
    <AbsoluteFill>
      <div
        style={{
          position: "absolute",
          [isBottom ? "bottom" : "top"]: 40,
          [isRight ? "right" : "left"]: 40,
          opacity,
          transform: \`translateY(\${isBottom ? -slideIn : slideIn}px)\`,
          backgroundColor: "rgba(15, 23, 42, 0.85)",
          padding: "12px 24px",
          borderRadius: 8,
          borderLeft: \`3px solid \${brand.cta}\`,
          fontFamily: brand.font,
          fontSize: 22,
          color: brand.light,
          fontWeight: 500,
          maxWidth: 500,
        }}
      >
        {text}
      </div>
    </AbsoluteFill>
  );
};
`;

const ROOT_TSX = `import { Composition } from "remotion";
import { QuoteCard, QuoteCardProps } from "./compositions/QuoteCard.ts";
import { SocialClip, SocialClipProps } from "./compositions/SocialClip.ts";
import { CourseIntro, CourseIntroProps } from "./compositions/CourseIntro.ts";
import { DataViz, DataVizProps } from "./compositions/DataViz.ts";
import { BrandedOverlay, BrandedOverlayProps } from "./compositions/BrandedOverlay.ts";

export const RemotionRoot: React.FC = () => {
  return (
    <>
      <Composition
        id="QuoteCard"
        component={QuoteCard}
        durationInFrames={150}
        fps={30}
        width={1080}
        height={1080}
        defaultProps={{ text: "Build and control agents to get huge results." } satisfies QuoteCardProps}
      />
      <Composition
        id="SocialClip"
        component={SocialClip}
        durationInFrames={150}
        fps={30}
        width={1080}
        height={1920}
        defaultProps={{ text: "Ship it." } satisfies SocialClipProps}
      />
      <Composition
        id="CourseIntro"
        component={CourseIntro}
        durationInFrames={150}
        fps={30}
        width={1920}
        height={1080}
        defaultProps={{ text: "Getting Started", category: "Agentic Development" } satisfies CourseIntroProps}
      />
      <Composition
        id="DataViz"
        component={DataViz}
        durationInFrames={150}
        fps={30}
        width={1080}
        height={1080}
        defaultProps={{ text: "Hours saved per week", value: "37", unit: "hrs" } satisfies DataVizProps}
      />
      <Composition
        id="BrandedOverlay"
        component={BrandedOverlay}
        durationInFrames={150}
        fps={30}
        width={1920}
        height={1080}
        defaultProps={{ text: "snappy.ai", position: "bottom-left" } satisfies BrandedOverlayProps}
      />
    </>
  );
};
`;

const INDEX_TS = `import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root.ts";
registerRoot(RemotionRoot);
`;

const COMP_TEMPLATE = (name: string, description: string) => `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand.ts";

export type ${name}Props = {
  text: string;
};

/** ${description} */
export const ${name}: React.FC<${name}Props> = ({ text }) => {
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [5, 25], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ backgroundColor: brand.cream, justifyContent: "center", alignItems: "center", fontFamily: brand.font, padding: 60 }}>
      <div style={{ opacity, fontSize: 48, fontWeight: 700, color: brand.ink, textAlign: "center" }}>
        {text}
      </div>
    </AbsoluteFill>
  );
};
`;

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Create the Remotion project at ~/projects/snappy-remotion if it doesn't exist.
 * Writes all template files and installs dependencies.
 */
export function scaffoldProject(): string {
  if (existsSync(join(PROJECT_DIR, "package.json"))) {
    return `Project already exists at ${PROJECT_DIR}. Delete it to re-scaffold.`;
  }

  // Create directory structure
  for (const dir of [PROJECT_DIR, SRC_DIR, COMP_DIR, LIB_DIR]) {
    mkdirSync(dir, { recursive: true });
  }

  // Write all template files
  const files: [string, string][] = [
    [join(PROJECT_DIR, "package.json"), PACKAGE_JSON],
    [join(PROJECT_DIR, "tsconfig.json"), TSCONFIG],
    [join(PROJECT_DIR, "remotion.config.ts"), REMOTION_CONFIG],
    [join(SRC_DIR, "index.ts"), INDEX_TS],
    [join(SRC_DIR, "Root.tsx"), ROOT_TSX],
    [join(LIB_DIR, "brand.ts"), BRAND_TS],
    [join(LIB_DIR, "transitions.ts"), TRANSITIONS_TS],
    [join(COMP_DIR, "QuoteCard.tsx"), QUOTE_CARD_TSX],
    [join(COMP_DIR, "SocialClip.tsx"), SOCIAL_CLIP_TSX],
    [join(COMP_DIR, "CourseIntro.tsx"), COURSE_INTRO_TSX],
    [join(COMP_DIR, "DataViz.tsx"), DATA_VIZ_TSX],
    [join(COMP_DIR, "BrandedOverlay.tsx"), BRANDED_OVERLAY_TSX],
  ];

  for (const [path, content] of files) {
    writeFileSync(path, content, "utf-8");
  }

  // Install dependencies
  exec("npm install", PROJECT_DIR, 120000);

  return `Scaffolded Remotion project at ${PROJECT_DIR}. ${files.length} files written. Dependencies installed.`;
}

/**
 * List available compositions from the project's Root.tsx.
 * Parses composition IDs from <Composition id="..."> in Root.tsx.
 */
export function listCompositions(): string[] {
  ensureProject();
  const rootPath = join(SRC_DIR, "Root.tsx");
  if (!existsSync(rootPath)) {
    throw new Error(`Root.tsx not found at ${rootPath}`);
  }
  const content = readFileSync(rootPath, "utf-8");
  const ids: string[] = [];
  const re = /id="([^"]+)"/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(content)) !== null) {
    ids.push(m[1]);
  }
  return ids;
}

export type RenderOpts = {
  output?: string;
  width?: number;
  height?: number;
  fps?: number;
  durationInFrames?: number;
  format?: keyof typeof FORMATS;
};

/**
 * Append a clip row to the ledger that "show my videos" reads, atomically with
 * the render. The engine produced real mp4s but nothing recorded the ledger, so
 * every video was an invisible orphan (the produce-vs-record seam). Best-effort
 * ffprobe for duration+dimensions; on any probe failure we still write the row
 * so a clip is NEVER invisible. A ledger failure can't break the render — the
 * mp4 is the deliverable, the row is bookkeeping.
 */
function recordClipLedgerRow(outputPath: string, compositionId: string): void {
  try {
    if (!existsSync(outputPath)) return;
    let duration_s = 0;
    let dimensions = "unknown";
    try {
      const out = execSync(
        `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -show_entries format=duration -of json ${JSON.stringify(outputPath)}`,
        { encoding: "utf8" },
      );
      const j = JSON.parse(out) as { streams?: Array<{ width?: number; height?: number }>; format?: { duration?: string } };
      const s = j.streams?.[0] ?? {};
      duration_s = j.format?.duration ? Math.round(Number(j.format.duration) * 10) / 10 : 0;
      dimensions = s.width && s.height ? `${s.width}x${s.height}` : "unknown";
    } catch { /* keep fallbacks; a clip with unknown metadata still beats an invisible one */ }
    mkdirSync(dirname(CLIPS_LEDGER), { recursive: true });
    appendFileSync(
      CLIPS_LEDGER,
      JSON.stringify({
        id: basename(outputPath, ".mp4"),
        name: compositionId || basename(outputPath, ".mp4"),
        duration_s,
        dimensions,
        created_at: new Date().toISOString(),
        thumbnail_url: null,
        format: "mp4",
        path: outputPath,
        composition: compositionId,
      }) + "\n",
    );
  } catch { /* ledger is bookkeeping; never break the render */ }
}

/**
 * Render a composition to MP4.
 * Uses `npx remotion render` in the project directory.
 * Returns the absolute path to the output file.
 */
export function render(
  compositionId: string,
  props: Record<string, unknown> = {},
  opts: RenderOpts = {},
): string {
  ensureProject();

  const ts = Date.now();
  const outputPath = opts.output || `/tmp/snappy-remotion-${compositionId.toLowerCase()}-${ts}.mp4`;

  // Resolve format defaults
  const fmt = opts.format ? FORMATS[opts.format] : undefined;
  const width = opts.width || fmt?.width;
  const height = opts.height || fmt?.height;
  const fps = opts.fps || fmt?.fps;
  const duration = opts.durationInFrames || fmt?.durationInFrames;

  const parts = [
    "npx", "remotion", "render",
    "src/index.ts",
    compositionId,
    `"${outputPath}"`,
  ];

  if (Object.keys(props).length > 0) {
    // Write props to a temp file to avoid shell escaping issues
    const propsPath = `/tmp/snappy-remotion-props-${ts}.json`;
    writeFileSync(propsPath, JSON.stringify(props), "utf-8");
    parts.push("--props", propsPath);
  }

  if (width) parts.push("--width", String(width));
  if (height) parts.push("--height", String(height));
  if (fps) parts.push("--fps", String(fps));
  // 2026-05-07: Remotion's `--frames` takes a RANGE, not a count. The old
  // `--frames 150` made the CLI think we wanted a single still ("Selected
  // a single frame. Assuming you want to output an image"), then it
  // rejected the .mp4 extension. For a full video we pass a range
  // "0-149" so callers who want to override duration still work; if no
  // duration is passed, we omit the flag and let the composition's
  // declared durationInFrames win.
  if (duration && duration > 0) parts.push(`--frames=0-${Math.max(0, duration - 1)}`);

  exec(parts.join(" "), PROJECT_DIR, 600000);

  // Record the clip atomically with the render — the produce-vs-record seam
  // that left every render an invisible orphan. Best-effort, never throws.
  recordClipLedgerRow(outputPath, compositionId);

  return outputPath;
}

/**
 * Streaming render — wraps `render()` with an async generator that yields
 * video.start + video.complete events for SSE relay. Mirrors the image-gen
 * NDJSON pattern (item 3) so the snappy-os dispatch handler can emit a
 * loading-state surface immediately and editMode-patch the preview when
 * the render lands.
 *
 * Auto-picks composition based on duration heuristic ("SocialClip" for
 * short clips, "QuoteCard" for the shortest, "BrandedOverlay" for longer)
 * unless an explicit composition is provided via opts.composition.
 */
export interface RemotionRenderArgs {
  prompt: string;
  duration: number;
  voice?: string;
  composition?: string;
  output?: string;
}

export type RemotionRenderEvent =
  | { event: "video.start"; id: string; prompt: string; composition: string }
  | { event: "video.complete"; id: string; url: string; prompt: string; composition: string }
  | { event: "video.error"; id: string; reason: string; prompt?: string };

export async function* remotionRender(args: RemotionRenderArgs): AsyncGenerator<RemotionRenderEvent> {
  const { prompt, duration, voice = "rachel", composition, output } = args;
  const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

  // Project must exist; if Remotion CLI not installed, yield error early.
  if (!existsSync(PROJECT_DIR)) {
    yield {
      event: "video.error",
      id,
      reason: "Remotion project not scaffolded; run: npx tsx state/lib/remotion.ts scaffold",
    };
    return;
  }

  // Heuristic composition pick: short = QuoteCard / SocialClip, long = BrandedOverlay.
  const pickedComposition = composition || (duration <= 5 ? "SocialClip" : duration <= 10 ? "QuoteCard" : "BrandedOverlay");

  yield { event: "video.start", id, prompt, composition: pickedComposition };

  try {
    const outputPath = render(pickedComposition, { text: prompt, voice }, output ? { output } : {});
    // Existing render() returns a local mp4 path. Convert to a file:// URL
    // the snappy-chat CardImage can mount. CDN upload is a future step
    // (mirror image-gen's CDN behavior when ready).
    const url = outputPath.startsWith("/") ? `file://${outputPath}` : outputPath;
    yield { event: "video.complete", id, url, prompt, composition: pickedComposition };
  } catch (err) {
    const reason = err instanceof Error ? err.message : String(err);
    yield { event: "video.error", id, reason, prompt };
  }
}

/**
 * Start the Remotion Studio dev server for visual editing.
 * Returns the Studio URL (default http://localhost:3000).
 * Runs as a background process -- caller is responsible for stopping it.
 */
export function preview(compositionId?: string, props?: Record<string, unknown>): string {
  ensureProject();

  const parts = ["npx", "remotion", "studio", "src/index.ts"];
  const child = spawn(parts[0], parts.slice(1), {
    cwd: PROJECT_DIR,
    detached: true,
    stdio: "ignore",
  });
  child.unref();

  const url = "http://localhost:3000";
  return compositionId ? `${url}/?composition=${compositionId}` : url;
}

/**
 * Scaffold a new composition from the template.
 * Creates the .tsx file and registers it in Root.tsx.
 * Returns the path to the new composition file.
 */
export function addComposition(name: string, description: string): string {
  ensureProject();

  if (!/^[A-Z][a-zA-Z0-9]+$/.test(name)) {
    throw new Error(`Composition name must be PascalCase: ${name}`);
  }

  const filePath = join(COMP_DIR, `${name}.tsx`);
  if (existsSync(filePath)) {
    throw new Error(`Composition already exists: ${filePath}`);
  }

  // Write the composition file
  writeFileSync(filePath, COMP_TEMPLATE(name, description), "utf-8");

  // Register in Root.tsx
  const rootPath = join(SRC_DIR, "Root.tsx");
  let root = readFileSync(rootPath, "utf-8");

  // Add import
  const importLine = `import { ${name}, ${name}Props } from "./compositions/${name}.ts";`;
  const lastImportIdx = root.lastIndexOf("import ");
  const lastImportEnd = root.indexOf("\n", lastImportIdx);
  root = root.slice(0, lastImportEnd + 1) + importLine + "\n" + root.slice(lastImportEnd + 1);

  // Add Composition before the closing </>
  const compBlock = `      <Composition
        id="${name}"
        component={${name}}
        durationInFrames={150}
        fps={30}
        width={1080}
        height={1080}
        defaultProps={{ text: "${description}" } satisfies ${name}Props}
      />\n`;
  const closingIdx = root.lastIndexOf("</>");
  root = root.slice(0, closingIdx) + compBlock + "    " + root.slice(closingIdx);

  writeFileSync(rootPath, root, "utf-8");

  return filePath;
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

if ((() => { try { return import.meta.url === `file://${realpathSync(process.argv[1])}`; } catch { return false; } })()) {
  (async () => {
    const [, , cmd, ...args] = process.argv;

    switch (cmd) {
      case "scaffold": {
        console.log(scaffoldProject());
        break;
      }
      case "list": {
        const comps = listCompositions();
        for (const c of comps) console.log(c);
        break;
      }
      case "render": {
        const [comp, ...rest] = args;
        if (!comp) {
          console.error("Usage: api.ts render <compositionId> [--props '{...}'] [--format vertical|square|landscape|youtube|story] [--output path]");
          process.exit(1);
        }
        let props: Record<string, unknown> = {};
        let output = "";
        let format = "";
        let width: number | undefined;
        let height: number | undefined;
        let fps: number | undefined;
        let durationInFrames: number | undefined;
        for (let i = 0; i < rest.length; i++) {
          if (rest[i] === "--props" && rest[i + 1]) { try { props = JSON.parse(rest[++i]); } catch (e) { console.error("Invalid JSON in --props:", e instanceof Error ? e.message : String(e)); process.exit(1); } continue; }
          if (rest[i] === "--output" && rest[i + 1]) { output = rest[++i]; continue; }
          if (rest[i] === "--format" && rest[i + 1]) { format = rest[++i]; continue; }
          if (rest[i] === "--width" && rest[i + 1]) { width = Number(rest[++i]); continue; }
          if (rest[i] === "--height" && rest[i + 1]) { height = Number(rest[++i]); continue; }
          if (rest[i] === "--fps" && rest[i + 1]) { fps = Number(rest[++i]); continue; }
          if (rest[i] === "--duration" && rest[i + 1]) { durationInFrames = Number(rest[++i]); continue; }
        }
        const outPath = render(comp, props, {
          output: output || undefined,
          format: (format as keyof typeof FORMATS) || undefined,
          width,
          height,
          fps,
          durationInFrames,
        });
        console.log(outPath);
        break;
      }
      case "preview": {
        const [comp] = args;
        const url = preview(comp || undefined);
        console.log(`Remotion Studio started: ${url}`);
        break;
      }
      case "add": {
        const [name, ...descParts] = args;
        const desc = descParts.join(" ");
        if (!name || !desc) {
          console.error("Usage: api.ts add <PascalCaseName> <description>");
          process.exit(1);
        }
        const path = addComposition(name, desc);
        console.log(`Created: ${path}`);
        break;
      }
      default:
        console.log("Usage: npx tsx api.ts [scaffold|list|render|preview|add] ...");
        console.log("");
        console.log("  scaffold                          Create the Remotion project");
        console.log("  list                              List available compositions");
        console.log("  render <comp> --props '{...}'     Render composition to MP4");
        console.log("  preview [comp]                    Start Remotion Studio");
        console.log("  add <Name> <description>          Add a new composition");
    }
  })();
}

scripts- helper scripts it can run

prose-only skill - 2 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).

how we check it- the checks, plus the last 10 runs

rubric auto no rubric declared
recent no runs actor/auditor: unverifiable
deps ffmpeg image

no recent runs logged - the eval contract is declared but nothing has been graded yet