#!/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.
*
* 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 "../snappy-remotion/api.ts";
*/
import { execSync, spawn } from "child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
import { join, basename } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROJECT_DIR = join(process.env.HOME || "/Users/robertboulos", "projects/snappy-remotion");
const SRC_DIR = join(PROJECT_DIR, "src");
const COMP_DIR = join(SRC_DIR, "compositions");
const LIB_DIR = join(SRC_DIR, "lib");
/** 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;
/** 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 ~/.claude/skills/snappy-remotion/api.ts scaffold\` first.`,
);
}
}
function exec(cmd: string, cwd?: string, timeoutMs = 300000): string {
return execSync(cmd, {
encoding: "utf-8",
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";
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";
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";
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";
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";
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";
import { SocialClip, SocialClipProps } from "./compositions/SocialClip";
import { CourseIntro, CourseIntroProps } from "./compositions/CourseIntro";
import { DataViz, DataVizProps } from "./compositions/DataViz";
import { BrandedOverlay, BrandedOverlayProps } from "./compositions/BrandedOverlay";
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";
registerRoot(RemotionRoot);
`;
const COMP_TEMPLATE = (name: string, description: string) => `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand";
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;
};
/**
* 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));
if (duration) parts.push("--frames", String(duration));
exec(parts.join(" "), PROJECT_DIR, 600000);
return outputPath;
}
/**
* 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}";`;
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
// ---------------------------------------------------------------------------
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-remotion",
description: "Snappy Remotion generates programmatic video from React components. Renders quote cards, social clips, course intros, data visualizations, branded overlays from text/images/data into MP4 without manual editing. Project lives at ~/projects/snappy-remotion. Triggers on: remotion, render video, quote card, social clip, course intro, data viz, branded overlay, programmatic video, video from text, animated quote, generate video, video template, batch render.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
add: {
args: ["name","description"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { name: { type: "string", description: "PascalCase composition name to create" }, description: { type: "string", description: "One-sentence description of the thing" } } },
},
list: {
args: [], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many compositions to return"),
} },
},
preview: {
args: ["composition-id"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { "composition-id": { type: "string", description: "Remotion composition id to open in Studio; omit for the whole project" } } },
},
render: {
args: ["composition-id"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { "composition-id": { type: "string", description: "Remotion composition id" } } },
},
scaffold: {
args: [], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "scaffold": {
console.log(scaffoldProject());
break;
}
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const comps = boundRows(listCompositions(), bound.limit);
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]) { props = JSON.parse(rest[++i]); 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");
}
})();
}
#!/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.
*
* 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 "../snappy-remotion/api.ts";
*/
import { execSync, spawn } from "child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
import { join, basename } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROJECT_DIR = join(process.env.HOME || "/Users/robertboulos", "projects/snappy-remotion");
const SRC_DIR = join(PROJECT_DIR, "src");
const COMP_DIR = join(SRC_DIR, "compositions");
const LIB_DIR = join(SRC_DIR, "lib");
/** 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;
/** 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 ~/.claude/skills/snappy-remotion/api.ts scaffold\` first.`,
);
}
}
function exec(cmd: string, cwd?: string, timeoutMs = 300000): string {
return execSync(cmd, {
encoding: "utf-8",
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";
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";
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";
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";
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";
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";
import { SocialClip, SocialClipProps } from "./compositions/SocialClip";
import { CourseIntro, CourseIntroProps } from "./compositions/CourseIntro";
import { DataViz, DataVizProps } from "./compositions/DataViz";
import { BrandedOverlay, BrandedOverlayProps } from "./compositions/BrandedOverlay";
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";
registerRoot(RemotionRoot);
`;
const COMP_TEMPLATE = (name: string, description: string) => `import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
import { brand } from "../lib/brand";
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;
};
/**
* 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));
if (duration) parts.push("--frames", String(duration));
exec(parts.join(" "), PROJECT_DIR, 600000);
return outputPath;
}
/**
* 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}";`;
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
// ---------------------------------------------------------------------------
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-remotion",
description: "Snappy Remotion generates programmatic video from React components. Renders quote cards, social clips, course intros, data visualizations, branded overlays from text/images/data into MP4 without manual editing. Project lives at ~/projects/snappy-remotion. Triggers on: remotion, render video, quote card, social clip, course intro, data viz, branded overlay, programmatic video, video from text, animated quote, generate video, video template, batch render.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
add: {
args: ["name","description"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { name: { type: "string", description: "PascalCase composition name to create" }, description: { type: "string", description: "One-sentence description of the thing" } } },
},
list: {
args: [], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many compositions to return"),
} },
},
preview: {
args: ["composition-id"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { "composition-id": { type: "string", description: "Remotion composition id to open in Studio; omit for the whole project" } } },
},
render: {
args: ["composition-id"], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
inputSchema: { properties: { "composition-id": { type: "string", description: "Remotion composition id" } } },
},
scaffold: {
args: [], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "scaffold": {
console.log(scaffoldProject());
break;
}
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const comps = boundRows(listCompositions(), bound.limit);
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]) { props = JSON.parse(rest[++i]); 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");
}
})();
}