#!/usr/bin/env npx tsx
/**
* snappy-ffmpeg/api.ts — Local ffmpeg primitive layer.
*
* Public API:
* probe(path) — JSON metadata via ffprobe
* trim(path, start, end, opts) — Keyframe or frame-accurate trim
* concat(paths, output) — Concatenate video files
* resizeLocal(path, format, output) — Letterbox resize to 16:9, 9:16, 1:1
* compress(path, opts) — H.264 web compression
* extractAudioLocal(path, fmt, out) — Extract audio track
* burnSubs(path, subs, output) — Burn SRT/ASS subtitles into video
* thumbnail(path, ts, output) — Extract a single frame as JPG
* loudnorm(path, lufs, output) — EBU R128 loudness normalization
* overlay(base, over, pos, out)— PiP or watermark overlay
*
* CLI:
* npx tsx api.ts probe <path>
* npx tsx api.ts trim <path> <start> <end>
* npx tsx api.ts resize <path> <format>
* npx tsx api.ts compress <path>
* npx tsx api.ts extract-audio <path> [format]
* npx tsx api.ts burn-subs <path> <subs-path>
* npx tsx api.ts thumbnail <path> [timestamp]
* npx tsx api.ts loudnorm <path>
* npx tsx api.ts concat <path1> <path2> [path3...]
* npx tsx api.ts overlay <base> <overlay> [position]
*/
import { env } from "../snappy-settings/load.ts";
import { execSync } from "child_process";
import { existsSync, realpathSync, unlinkSync, writeFileSync } from "fs";
import { extname, basename } from "path";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FFMPEG = "/opt/homebrew/bin/ffmpeg";
const FFPROBE = "/opt/homebrew/bin/ffprobe";
function tmpPath(operation: string, ext: string): string {
const stamp = Date.now();
return `/tmp/snappy-ffmpeg-${operation}-${stamp}${ext}`;
}
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export type Result = {
ok: boolean;
output: string;
path?: string;
error?: string;
durationMs: number;
};
function run(cmd: string, outputPath?: string): Result {
const start = Date.now();
try {
const stdout = execSync(cmd, {
encoding: "utf-8",
maxBuffer: 50 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
return {
ok: true,
output: stdout.trim(),
path: outputPath,
durationMs: Date.now() - start,
};
} catch (e: any) {
return {
ok: false,
output: "",
error: e.stderr?.toString()?.trim() || e.message,
path: outputPath,
durationMs: Date.now() - start,
};
}
}
function assertFile(path: string): void {
if (!existsSync(path)) throw new Error(`File not found: ${path}`);
}
function q(s: string): string {
// Shell-escape a path
return `'${s.replace(/'/g, "'\\''")}'`;
}
// ---------------------------------------------------------------------------
// 1. probe
// ---------------------------------------------------------------------------
export type ProbeInfo = {
duration: number;
width: number;
height: number;
codec_video: string;
codec_audio: string;
frame_rate: string;
bit_rate: number;
audio_sample_rate: number;
audio_channels: number;
format: string;
raw: any;
};
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `media` family draws seven kinds, `media-video` among them, and NO read
* reached any of them: the runner's name route is exactly `snappy-<family>`
* and this hand is `snappy-ffmpeg`, while it is the one hand on this Mac that
* can say anything measured about a cut.
*
* `probe` is the read, and the fold prints the face's own two key names —
* `title` and `durationSeconds` — BESIDE the ffprobe summary, never instead of
* it, so every reader of `duration`/`width`/`codec_video` still reads them.
*
* A FILE WITH NO VIDEO TRACK IS NOT A CUT. `VideoPreviewView` treats a finite
* `durationSeconds` as proof a cut EXISTS, so handing it an mp3's duration
* would draw "A cut exists" over a file with no picture in it. The absence is
* structural — no video stream, no `durationSeconds` — and it says which file
* and why in `absenceWords` ⟨CLAUDE.md §10⟩. No poster is passed: extracting a
* frame WRITES a file, and this verb is a read.
*/
export interface MediaVideoAnswer {
readonly title: string;
readonly durationSeconds?: number;
readonly absenceWords?: string;
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `media-video` → VideoPreview{title?, durationSeconds?, posterUrl?,
* absenceWords?, captions?}⟩. */
export function mediaVideoFace(path: string, info: Pick<ProbeInfo, "duration" | "width" | "height"> | undefined): MediaVideoAnswer {
const title = path.split("/").filter(Boolean).at(-1) ?? path;
if (info === undefined) {
return { title, absenceWords: `ffprobe could not read ${title} on this Mac, so nothing about it has been measured.` };
}
if (!(info.width > 0 && info.height > 0)) {
return { title, absenceWords: `${title} has no video track — ffprobe found no picture stream in it, only ${info.duration > 0 ? `${Math.round(info.duration)}s of it` : "the container"}.` };
}
return { title, ...(Number.isFinite(info.duration) && info.duration > 0 ? { durationSeconds: info.duration } : {}) };
}
export function probe(path: string): Result & { info?: ProbeInfo } {
assertFile(path);
const cmd = `${FFPROBE} -v quiet -print_format json -show_format -show_streams ${q(path)}`;
const result = run(cmd);
if (!result.ok) return result;
try {
const raw = JSON.parse(result.output);
const videoStream = raw.streams?.find((s: any) => s.codec_type === "video") || {};
const audioStream = raw.streams?.find((s: any) => s.codec_type === "audio") || {};
const info: ProbeInfo = {
duration: parseFloat(raw.format?.duration || "0"),
width: videoStream.width || 0,
height: videoStream.height || 0,
codec_video: videoStream.codec_name || "",
codec_audio: audioStream.codec_name || "",
frame_rate: videoStream.r_frame_rate || "",
bit_rate: parseInt(raw.format?.bit_rate || "0", 10),
audio_sample_rate: parseInt(audioStream.sample_rate || "0", 10),
audio_channels: audioStream.channels || 0,
format: raw.format?.format_name || "",
raw,
};
return { ...result, info };
} catch {
return { ...result, error: "Failed to parse ffprobe JSON" };
}
}
// ---------------------------------------------------------------------------
// 2. trim
// ---------------------------------------------------------------------------
export type TrimOpts = {
/** If true, re-encode for frame-accurate cuts. Default false (keyframe-aligned, -c copy). */
frameAccurate?: boolean;
/** Output path. Auto-generated if omitted. */
output?: string;
};
export function trim(path: string, start: string, end: string, opts?: TrimOpts): Result {
assertFile(path);
const ext = extname(path) || ".mp4";
const out = opts?.output || tmpPath("trim", ext);
let cmd: string;
if (opts?.frameAccurate) {
cmd = `${FFMPEG} -y -i ${q(path)} -ss ${q(start)} -to ${q(end)} -c:v libx264 -crf 18 -preset medium -c:a aac -b:a 192k -movflags +faststart ${q(out)}`;
} else {
cmd = `${FFMPEG} -y -ss ${q(start)} -to ${q(end)} -i ${q(path)} -c copy ${q(out)}`;
}
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 3. concat
// ---------------------------------------------------------------------------
export function concat(paths: string[], output: string): Result {
for (const p of paths) assertFile(p);
// Check if all files share the same codec — use concat demuxer if so
const codecs = paths.map((p) => {
const r = probe(p);
return r.info ? `${r.info.codec_video}:${r.info.codec_audio}` : "";
});
const sameCodec = codecs.every((c) => c === codecs[0] && c !== "");
if (sameCodec) {
// Fast concat via demuxer
const listFile = `/tmp/snappy-ffmpeg-concat-${Date.now()}.txt`;
const content = paths.map((p) => `file ${q(p)}`).join("\n");
writeFileSync(listFile, content);
const cmd = `${FFMPEG} -y -f concat -safe 0 -i ${q(listFile)} -c copy ${q(output)}`;
const result = run(cmd, output);
try { unlinkSync(listFile); } catch {}
return result;
} else {
// Re-encode concat via filter_complex
const inputs = paths.map((p) => `-i ${q(p)}`).join(" ");
const n = paths.length;
const filterParts = Array.from({ length: n }, (_, i) => `[${i}:v:0][${i}:a:0]`).join("");
const cmd = `${FFMPEG} -y ${inputs} -filter_complex "${filterParts}concat=n=${n}:v=1:a=1[outv][outa]" -map "[outv]" -map "[outa]" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 192k -movflags +faststart ${q(output)}`;
return run(cmd, output);
}
}
// ---------------------------------------------------------------------------
// 4. resize
// ---------------------------------------------------------------------------
type AspectFormat = "16:9" | "9:16" | "1:1";
const RESIZE_DIMS: Record<AspectFormat, { w: number; h: number }> = {
"16:9": { w: 1920, h: 1080 },
"9:16": { w: 1080, h: 1920 },
"1:1": { w: 1080, h: 1080 },
};
export function resizeLocal(path: string, format: AspectFormat, output?: string): Result {
assertFile(path);
const { w, h } = RESIZE_DIMS[format];
const ext = extname(path) || ".mp4";
const out = output || tmpPath("resize", ext);
const vf = `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:-1:-1:color=black`;
const cmd = `${FFMPEG} -y -i ${q(path)} -vf "${vf}" -c:v libx264 -crf 23 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 5. compress
// ---------------------------------------------------------------------------
export type CompressOpts = {
crf?: number;
preset?: string;
faststart?: boolean;
output?: string;
};
export function compress(path: string, opts?: CompressOpts): Result {
assertFile(path);
const crf = opts?.crf ?? 23;
const preset = opts?.preset ?? "medium";
const faststart = opts?.faststart !== false;
const ext = extname(path) || ".mp4";
const out = opts?.output || tmpPath("compress", ext);
let cmd = `${FFMPEG} -y -i ${q(path)} -c:v libx264 -crf ${crf} -preset ${preset} -c:a aac -b:a 128k`;
if (faststart) cmd += " -movflags +faststart";
cmd += ` ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 6. extractAudio
// ---------------------------------------------------------------------------
type AudioFormat = "m4a" | "mp3" | "wav";
const AUDIO_CODEC: Record<AudioFormat, string> = {
m4a: "-c:a aac -b:a 192k",
mp3: "-c:a libmp3lame -b:a 192k",
wav: "-c:a pcm_s16le",
};
export function extractAudioLocal(path: string, format?: AudioFormat, output?: string): Result {
assertFile(path);
const fmt = format || "m4a";
const out = output || tmpPath("audio", `.${fmt}`);
const codec = AUDIO_CODEC[fmt];
const cmd = `${FFMPEG} -y -i ${q(path)} -vn ${codec} ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 7. burnSubs
// ---------------------------------------------------------------------------
export function burnSubs(path: string, subsPath: string, output?: string): Result {
assertFile(path);
assertFile(subsPath);
const ext = extname(path) || ".mp4";
const out = output || tmpPath("subs", ext);
const subsExt = extname(subsPath).toLowerCase();
let vf: string;
if (subsExt === ".ass" || subsExt === ".ssa") {
vf = `ass=${q(subsPath).slice(1, -1)}`; // ass filter takes unquoted path
} else {
// SRT and other text-based subs via subtitles filter
vf = `subtitles=${q(subsPath).slice(1, -1)}`;
}
const cmd = `${FFMPEG} -y -i ${q(path)} -vf "${vf}" -c:v libx264 -crf 18 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 8. thumbnail
// ---------------------------------------------------------------------------
export function thumbnail(path: string, timestamp?: string, output?: string): Result {
assertFile(path);
const ts = timestamp || "00:00:05";
const out = output || tmpPath("thumb", ".jpg");
const cmd = `${FFMPEG} -y -ss ${q(ts)} -i ${q(path)} -frames:v 1 -q:v 2 ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 9. loudnorm
// ---------------------------------------------------------------------------
export function loudnorm(path: string, targetLufs?: number, output?: string): Result {
assertFile(path);
const lufs = targetLufs ?? -14;
const ext = extname(path) || ".mp4";
const out = output || tmpPath("loudnorm", ext);
// Two-pass loudnorm: first pass measures, second pass applies.
// For simplicity we use single-pass (close enough for most content).
const af = `loudnorm=I=${lufs}:TP=-1.5:LRA=11`;
const cmd = `${FFMPEG} -y -i ${q(path)} -af "${af}" -c:v copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 10. overlay
// ---------------------------------------------------------------------------
export type OverlayPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center";
const OVERLAY_XY: Record<OverlayPosition, string> = {
"top-left": "10:10",
"top-right": "main_w-overlay_w-10:10",
"bottom-left": "10:main_h-overlay_h-10",
"bottom-right": "main_w-overlay_w-10:main_h-overlay_h-10",
"center": "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
};
export function overlay(
basePath: string,
overlayPath: string,
position?: OverlayPosition,
output?: string,
): Result {
assertFile(basePath);
assertFile(overlayPath);
const pos = position || "bottom-right";
const ext = extname(basePath) || ".mp4";
const out = output || tmpPath("overlay", ext);
const xy = OVERLAY_XY[pos];
const cmd = `${FFMPEG} -y -i ${q(basePath)} -i ${q(overlayPath)} -filter_complex "overlay=${xy}" -c:v libx264 -crf 23 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 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.
*/
export const HAND_CONTRACT = {
skill: "snappy-ffmpeg",
description: "Local ffmpeg primitive layer for media manipulation. Probe, trim, concat, resize, compress, extract audio, burn subtitles, thumbnail, loudness normalize, overlay. Runs locally on this Mac (NOT via SSH to Mac Mini — that is snappy-video). Building block called by snappy-remotion, snappy-content, snappy-video for local ops. Triggers on: ffmpeg local, probe video, trim clip, concat videos, resize video, compress video local, extract audio local, burn subs local, thumbnail local, loudnorm, overlay, pip, watermark, ffprobe.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
"burn-subs": {
args: ["path","subs-path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, "subs-path": { type: "string", description: "Subtitle file burned into the picture" } } },
},
compress: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
concat: {
args: ["path-1","path-2"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "path-1": { type: "string", description: "First clip in the join" }, "path-2": { type: "string", description: "Second clip in the join" } } },
},
"extract-audio": {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
loudnorm: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
overlay: {
args: ["base","overlay","position?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { base: { type: "string", description: "Clip the overlay is drawn onto" }, overlay: { type: "string", description: "Image or clip drawn on top" }, position: { type: "string", description: "Corner the overlay sits in", enum: ["tl", "tr", "bl", "br", "center"] } } },
},
probe: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The family
// is `media`; this hand is `snappy-ffmpeg`, so the runner's name route
// (`snappy-<family>`) could not reach it, and the seven media faces had
// no read at all while this hand measured every cut on this Mac.
face: "media-video",
args: ["path"], effect: "write-reversible",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
resize: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
thumbnail: {
args: ["path","timestamp?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, timestamp: { type: "string", description: "Position the still is taken at, as HH:MM:SS" } } },
},
trim: {
args: ["path","start","end"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, start: { type: "string", description: "Trim start as HH:MM:SS" }, end: { type: "string", description: "Trim end as HH:MM:SS" } } },
},
},
} 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])}`) {
const [, , cmd, ...args] = process.argv;
function die(msg: string): never {
console.error(msg);
process.exit(1);
}
function printResult(r: Result): void {
if (r.ok) {
if (r.path) console.log(`OK → ${r.path} (${r.durationMs}ms)`);
if (r.output && !r.path) console.log(r.output);
} else {
console.error(`FAIL (${r.durationMs}ms): ${r.error}`);
process.exit(1);
}
}
if (!cmd || cmd === "help") {
console.log(`snappy-ffmpeg CLI — local ffmpeg primitive layer
probe <path> Media metadata as JSON
trim <path> <start> <end> Fast trim (keyframe-aligned)
resize <path> <16:9|9:16|1:1> Letterbox resize
compress <path> H.264 web compression (crf=23)
extract-audio <path> [m4a|mp3|wav] Extract audio track
burn-subs <path> <subs-path> Burn SRT/ASS subtitles
thumbnail <path> [timestamp] Extract frame as JPG
loudnorm <path> EBU R128 loudness normalization
concat <path1> <path2> [path3...] Concatenate files
overlay <base> <overlay> [position] PiP / watermark overlay
help This message`);
process.exit(0);
}
if (cmd === "probe") {
if (!args[0]) die("Usage: probe <path>");
const r = probe(args[0]);
if (r.ok && (r as any).info) {
const { raw, ...summary } = (r as any).info;
// The face's keys are added BESIDE ffprobe's own summary; nothing moves.
console.log(JSON.stringify({ ...summary, ...mediaVideoFace(args[0], summary) }, null, 2));
} else {
printResult(r);
}
} else if (cmd === "trim") {
if (args.length < 3) die("Usage: trim <path> <start> <end>");
printResult(trim(args[0], args[1], args[2]));
} else if (cmd === "resize") {
if (args.length < 2) die("Usage: resize <path> <16:9|9:16|1:1>");
printResult(resizeLocal(args[0], args[1] as AspectFormat));
} else if (cmd === "compress") {
if (!args[0]) die("Usage: compress <path>");
printResult(compress(args[0]));
} else if (cmd === "extract-audio") {
if (!args[0]) die("Usage: extract-audio <path> [m4a|mp3|wav]");
printResult(extractAudioLocal(args[0], (args[1] as AudioFormat) || undefined));
} else if (cmd === "burn-subs") {
if (args.length < 2) die("Usage: burn-subs <path> <subs-path>");
printResult(burnSubs(args[0], args[1]));
} else if (cmd === "thumbnail") {
if (!args[0]) die("Usage: thumbnail <path> [timestamp]");
printResult(thumbnail(args[0], args[1] || undefined));
} else if (cmd === "loudnorm") {
if (!args[0]) die("Usage: loudnorm <path>");
printResult(loudnorm(args[0]));
} else if (cmd === "concat") {
if (args.length < 2) die("Usage: concat <path1> <path2> [path3...]");
const out = tmpPath("concat", extname(args[0]) || ".mp4");
printResult(concat(args, out));
} else if (cmd === "overlay") {
if (args.length < 2) die("Usage: overlay <base> <overlay> [position]");
printResult(overlay(args[0], args[1], (args[2] as OverlayPosition) || undefined));
} else {
die(`Unknown command: ${cmd}. Run 'npx tsx api.ts help' for usage.`);
}
}
#!/usr/bin/env npx tsx
/**
* snappy-ffmpeg/api.ts — Local ffmpeg primitive layer.
*
* Public API:
* probe(path) — JSON metadata via ffprobe
* trim(path, start, end, opts) — Keyframe or frame-accurate trim
* concat(paths, output) — Concatenate video files
* resizeLocal(path, format, output) — Letterbox resize to 16:9, 9:16, 1:1
* compress(path, opts) — H.264 web compression
* extractAudioLocal(path, fmt, out) — Extract audio track
* burnSubs(path, subs, output) — Burn SRT/ASS subtitles into video
* thumbnail(path, ts, output) — Extract a single frame as JPG
* loudnorm(path, lufs, output) — EBU R128 loudness normalization
* overlay(base, over, pos, out)— PiP or watermark overlay
*
* CLI:
* npx tsx api.ts probe <path>
* npx tsx api.ts trim <path> <start> <end>
* npx tsx api.ts resize <path> <format>
* npx tsx api.ts compress <path>
* npx tsx api.ts extract-audio <path> [format]
* npx tsx api.ts burn-subs <path> <subs-path>
* npx tsx api.ts thumbnail <path> [timestamp]
* npx tsx api.ts loudnorm <path>
* npx tsx api.ts concat <path1> <path2> [path3...]
* npx tsx api.ts overlay <base> <overlay> [position]
*/
import { env } from "../snappy-settings/load.ts";
import { execSync } from "child_process";
import { existsSync, realpathSync, unlinkSync, writeFileSync } from "fs";
import { extname, basename } from "path";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FFMPEG = "/opt/homebrew/bin/ffmpeg";
const FFPROBE = "/opt/homebrew/bin/ffprobe";
function tmpPath(operation: string, ext: string): string {
const stamp = Date.now();
return `/tmp/snappy-ffmpeg-${operation}-${stamp}${ext}`;
}
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export type Result = {
ok: boolean;
output: string;
path?: string;
error?: string;
durationMs: number;
};
function run(cmd: string, outputPath?: string): Result {
const start = Date.now();
try {
const stdout = execSync(cmd, {
encoding: "utf-8",
maxBuffer: 50 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
return {
ok: true,
output: stdout.trim(),
path: outputPath,
durationMs: Date.now() - start,
};
} catch (e: any) {
return {
ok: false,
output: "",
error: e.stderr?.toString()?.trim() || e.message,
path: outputPath,
durationMs: Date.now() - start,
};
}
}
function assertFile(path: string): void {
if (!existsSync(path)) throw new Error(`File not found: ${path}`);
}
function q(s: string): string {
// Shell-escape a path
return `'${s.replace(/'/g, "'\\''")}'`;
}
// ---------------------------------------------------------------------------
// 1. probe
// ---------------------------------------------------------------------------
export type ProbeInfo = {
duration: number;
width: number;
height: number;
codec_video: string;
codec_audio: string;
frame_rate: string;
bit_rate: number;
audio_sample_rate: number;
audio_channels: number;
format: string;
raw: any;
};
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `media` family draws seven kinds, `media-video` among them, and NO read
* reached any of them: the runner's name route is exactly `snappy-<family>`
* and this hand is `snappy-ffmpeg`, while it is the one hand on this Mac that
* can say anything measured about a cut.
*
* `probe` is the read, and the fold prints the face's own two key names —
* `title` and `durationSeconds` — BESIDE the ffprobe summary, never instead of
* it, so every reader of `duration`/`width`/`codec_video` still reads them.
*
* A FILE WITH NO VIDEO TRACK IS NOT A CUT. `VideoPreviewView` treats a finite
* `durationSeconds` as proof a cut EXISTS, so handing it an mp3's duration
* would draw "A cut exists" over a file with no picture in it. The absence is
* structural — no video stream, no `durationSeconds` — and it says which file
* and why in `absenceWords` ⟨CLAUDE.md §10⟩. No poster is passed: extracting a
* frame WRITES a file, and this verb is a read.
*/
export interface MediaVideoAnswer {
readonly title: string;
readonly durationSeconds?: number;
readonly absenceWords?: string;
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `media-video` → VideoPreview{title?, durationSeconds?, posterUrl?,
* absenceWords?, captions?}⟩. */
export function mediaVideoFace(path: string, info: Pick<ProbeInfo, "duration" | "width" | "height"> | undefined): MediaVideoAnswer {
const title = path.split("/").filter(Boolean).at(-1) ?? path;
if (info === undefined) {
return { title, absenceWords: `ffprobe could not read ${title} on this Mac, so nothing about it has been measured.` };
}
if (!(info.width > 0 && info.height > 0)) {
return { title, absenceWords: `${title} has no video track — ffprobe found no picture stream in it, only ${info.duration > 0 ? `${Math.round(info.duration)}s of it` : "the container"}.` };
}
return { title, ...(Number.isFinite(info.duration) && info.duration > 0 ? { durationSeconds: info.duration } : {}) };
}
export function probe(path: string): Result & { info?: ProbeInfo } {
assertFile(path);
const cmd = `${FFPROBE} -v quiet -print_format json -show_format -show_streams ${q(path)}`;
const result = run(cmd);
if (!result.ok) return result;
try {
const raw = JSON.parse(result.output);
const videoStream = raw.streams?.find((s: any) => s.codec_type === "video") || {};
const audioStream = raw.streams?.find((s: any) => s.codec_type === "audio") || {};
const info: ProbeInfo = {
duration: parseFloat(raw.format?.duration || "0"),
width: videoStream.width || 0,
height: videoStream.height || 0,
codec_video: videoStream.codec_name || "",
codec_audio: audioStream.codec_name || "",
frame_rate: videoStream.r_frame_rate || "",
bit_rate: parseInt(raw.format?.bit_rate || "0", 10),
audio_sample_rate: parseInt(audioStream.sample_rate || "0", 10),
audio_channels: audioStream.channels || 0,
format: raw.format?.format_name || "",
raw,
};
return { ...result, info };
} catch {
return { ...result, error: "Failed to parse ffprobe JSON" };
}
}
// ---------------------------------------------------------------------------
// 2. trim
// ---------------------------------------------------------------------------
export type TrimOpts = {
/** If true, re-encode for frame-accurate cuts. Default false (keyframe-aligned, -c copy). */
frameAccurate?: boolean;
/** Output path. Auto-generated if omitted. */
output?: string;
};
export function trim(path: string, start: string, end: string, opts?: TrimOpts): Result {
assertFile(path);
const ext = extname(path) || ".mp4";
const out = opts?.output || tmpPath("trim", ext);
let cmd: string;
if (opts?.frameAccurate) {
cmd = `${FFMPEG} -y -i ${q(path)} -ss ${q(start)} -to ${q(end)} -c:v libx264 -crf 18 -preset medium -c:a aac -b:a 192k -movflags +faststart ${q(out)}`;
} else {
cmd = `${FFMPEG} -y -ss ${q(start)} -to ${q(end)} -i ${q(path)} -c copy ${q(out)}`;
}
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 3. concat
// ---------------------------------------------------------------------------
export function concat(paths: string[], output: string): Result {
for (const p of paths) assertFile(p);
// Check if all files share the same codec — use concat demuxer if so
const codecs = paths.map((p) => {
const r = probe(p);
return r.info ? `${r.info.codec_video}:${r.info.codec_audio}` : "";
});
const sameCodec = codecs.every((c) => c === codecs[0] && c !== "");
if (sameCodec) {
// Fast concat via demuxer
const listFile = `/tmp/snappy-ffmpeg-concat-${Date.now()}.txt`;
const content = paths.map((p) => `file ${q(p)}`).join("\n");
writeFileSync(listFile, content);
const cmd = `${FFMPEG} -y -f concat -safe 0 -i ${q(listFile)} -c copy ${q(output)}`;
const result = run(cmd, output);
try { unlinkSync(listFile); } catch {}
return result;
} else {
// Re-encode concat via filter_complex
const inputs = paths.map((p) => `-i ${q(p)}`).join(" ");
const n = paths.length;
const filterParts = Array.from({ length: n }, (_, i) => `[${i}:v:0][${i}:a:0]`).join("");
const cmd = `${FFMPEG} -y ${inputs} -filter_complex "${filterParts}concat=n=${n}:v=1:a=1[outv][outa]" -map "[outv]" -map "[outa]" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 192k -movflags +faststart ${q(output)}`;
return run(cmd, output);
}
}
// ---------------------------------------------------------------------------
// 4. resize
// ---------------------------------------------------------------------------
type AspectFormat = "16:9" | "9:16" | "1:1";
const RESIZE_DIMS: Record<AspectFormat, { w: number; h: number }> = {
"16:9": { w: 1920, h: 1080 },
"9:16": { w: 1080, h: 1920 },
"1:1": { w: 1080, h: 1080 },
};
export function resizeLocal(path: string, format: AspectFormat, output?: string): Result {
assertFile(path);
const { w, h } = RESIZE_DIMS[format];
const ext = extname(path) || ".mp4";
const out = output || tmpPath("resize", ext);
const vf = `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:-1:-1:color=black`;
const cmd = `${FFMPEG} -y -i ${q(path)} -vf "${vf}" -c:v libx264 -crf 23 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 5. compress
// ---------------------------------------------------------------------------
export type CompressOpts = {
crf?: number;
preset?: string;
faststart?: boolean;
output?: string;
};
export function compress(path: string, opts?: CompressOpts): Result {
assertFile(path);
const crf = opts?.crf ?? 23;
const preset = opts?.preset ?? "medium";
const faststart = opts?.faststart !== false;
const ext = extname(path) || ".mp4";
const out = opts?.output || tmpPath("compress", ext);
let cmd = `${FFMPEG} -y -i ${q(path)} -c:v libx264 -crf ${crf} -preset ${preset} -c:a aac -b:a 128k`;
if (faststart) cmd += " -movflags +faststart";
cmd += ` ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 6. extractAudio
// ---------------------------------------------------------------------------
type AudioFormat = "m4a" | "mp3" | "wav";
const AUDIO_CODEC: Record<AudioFormat, string> = {
m4a: "-c:a aac -b:a 192k",
mp3: "-c:a libmp3lame -b:a 192k",
wav: "-c:a pcm_s16le",
};
export function extractAudioLocal(path: string, format?: AudioFormat, output?: string): Result {
assertFile(path);
const fmt = format || "m4a";
const out = output || tmpPath("audio", `.${fmt}`);
const codec = AUDIO_CODEC[fmt];
const cmd = `${FFMPEG} -y -i ${q(path)} -vn ${codec} ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 7. burnSubs
// ---------------------------------------------------------------------------
export function burnSubs(path: string, subsPath: string, output?: string): Result {
assertFile(path);
assertFile(subsPath);
const ext = extname(path) || ".mp4";
const out = output || tmpPath("subs", ext);
const subsExt = extname(subsPath).toLowerCase();
let vf: string;
if (subsExt === ".ass" || subsExt === ".ssa") {
vf = `ass=${q(subsPath).slice(1, -1)}`; // ass filter takes unquoted path
} else {
// SRT and other text-based subs via subtitles filter
vf = `subtitles=${q(subsPath).slice(1, -1)}`;
}
const cmd = `${FFMPEG} -y -i ${q(path)} -vf "${vf}" -c:v libx264 -crf 18 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 8. thumbnail
// ---------------------------------------------------------------------------
export function thumbnail(path: string, timestamp?: string, output?: string): Result {
assertFile(path);
const ts = timestamp || "00:00:05";
const out = output || tmpPath("thumb", ".jpg");
const cmd = `${FFMPEG} -y -ss ${q(ts)} -i ${q(path)} -frames:v 1 -q:v 2 ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 9. loudnorm
// ---------------------------------------------------------------------------
export function loudnorm(path: string, targetLufs?: number, output?: string): Result {
assertFile(path);
const lufs = targetLufs ?? -14;
const ext = extname(path) || ".mp4";
const out = output || tmpPath("loudnorm", ext);
// Two-pass loudnorm: first pass measures, second pass applies.
// For simplicity we use single-pass (close enough for most content).
const af = `loudnorm=I=${lufs}:TP=-1.5:LRA=11`;
const cmd = `${FFMPEG} -y -i ${q(path)} -af "${af}" -c:v copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 10. overlay
// ---------------------------------------------------------------------------
export type OverlayPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center";
const OVERLAY_XY: Record<OverlayPosition, string> = {
"top-left": "10:10",
"top-right": "main_w-overlay_w-10:10",
"bottom-left": "10:main_h-overlay_h-10",
"bottom-right": "main_w-overlay_w-10:main_h-overlay_h-10",
"center": "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
};
export function overlay(
basePath: string,
overlayPath: string,
position?: OverlayPosition,
output?: string,
): Result {
assertFile(basePath);
assertFile(overlayPath);
const pos = position || "bottom-right";
const ext = extname(basePath) || ".mp4";
const out = output || tmpPath("overlay", ext);
const xy = OVERLAY_XY[pos];
const cmd = `${FFMPEG} -y -i ${q(basePath)} -i ${q(overlayPath)} -filter_complex "overlay=${xy}" -c:v libx264 -crf 23 -preset medium -c:a copy -movflags +faststart ${q(out)}`;
return run(cmd, out);
}
// ---------------------------------------------------------------------------
// 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.
*/
export const HAND_CONTRACT = {
skill: "snappy-ffmpeg",
description: "Local ffmpeg primitive layer for media manipulation. Probe, trim, concat, resize, compress, extract audio, burn subtitles, thumbnail, loudness normalize, overlay. Runs locally on this Mac (NOT via SSH to Mac Mini — that is snappy-video). Building block called by snappy-remotion, snappy-content, snappy-video for local ops. Triggers on: ffmpeg local, probe video, trim clip, concat videos, resize video, compress video local, extract audio local, burn subs local, thumbnail local, loudnorm, overlay, pip, watermark, ffprobe.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
"burn-subs": {
args: ["path","subs-path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, "subs-path": { type: "string", description: "Subtitle file burned into the picture" } } },
},
compress: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
concat: {
args: ["path-1","path-2"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "path-1": { type: "string", description: "First clip in the join" }, "path-2": { type: "string", description: "Second clip in the join" } } },
},
"extract-audio": {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
loudnorm: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
overlay: {
args: ["base","overlay","position?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { base: { type: "string", description: "Clip the overlay is drawn onto" }, overlay: { type: "string", description: "Image or clip drawn on top" }, position: { type: "string", description: "Corner the overlay sits in", enum: ["tl", "tr", "bl", "br", "center"] } } },
},
probe: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The family
// is `media`; this hand is `snappy-ffmpeg`, so the runner's name route
// (`snappy-<family>`) could not reach it, and the seven media faces had
// no read at all while this hand measured every cut on this Mac.
face: "media-video",
args: ["path"], effect: "write-reversible",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
resize: {
args: ["path"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" } } },
},
thumbnail: {
args: ["path","timestamp?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, timestamp: { type: "string", description: "Position the still is taken at, as HH:MM:SS" } } },
},
trim: {
args: ["path","start","end"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { path: { type: "string", description: "Path of the media file the verb reads" }, start: { type: "string", description: "Trim start as HH:MM:SS" }, end: { type: "string", description: "Trim end as HH:MM:SS" } } },
},
},
} 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])}`) {
const [, , cmd, ...args] = process.argv;
function die(msg: string): never {
console.error(msg);
process.exit(1);
}
function printResult(r: Result): void {
if (r.ok) {
if (r.path) console.log(`OK → ${r.path} (${r.durationMs}ms)`);
if (r.output && !r.path) console.log(r.output);
} else {
console.error(`FAIL (${r.durationMs}ms): ${r.error}`);
process.exit(1);
}
}
if (!cmd || cmd === "help") {
console.log(`snappy-ffmpeg CLI — local ffmpeg primitive layer
probe <path> Media metadata as JSON
trim <path> <start> <end> Fast trim (keyframe-aligned)
resize <path> <16:9|9:16|1:1> Letterbox resize
compress <path> H.264 web compression (crf=23)
extract-audio <path> [m4a|mp3|wav] Extract audio track
burn-subs <path> <subs-path> Burn SRT/ASS subtitles
thumbnail <path> [timestamp] Extract frame as JPG
loudnorm <path> EBU R128 loudness normalization
concat <path1> <path2> [path3...] Concatenate files
overlay <base> <overlay> [position] PiP / watermark overlay
help This message`);
process.exit(0);
}
if (cmd === "probe") {
if (!args[0]) die("Usage: probe <path>");
const r = probe(args[0]);
if (r.ok && (r as any).info) {
const { raw, ...summary } = (r as any).info;
// The face's keys are added BESIDE ffprobe's own summary; nothing moves.
console.log(JSON.stringify({ ...summary, ...mediaVideoFace(args[0], summary) }, null, 2));
} else {
printResult(r);
}
} else if (cmd === "trim") {
if (args.length < 3) die("Usage: trim <path> <start> <end>");
printResult(trim(args[0], args[1], args[2]));
} else if (cmd === "resize") {
if (args.length < 2) die("Usage: resize <path> <16:9|9:16|1:1>");
printResult(resizeLocal(args[0], args[1] as AspectFormat));
} else if (cmd === "compress") {
if (!args[0]) die("Usage: compress <path>");
printResult(compress(args[0]));
} else if (cmd === "extract-audio") {
if (!args[0]) die("Usage: extract-audio <path> [m4a|mp3|wav]");
printResult(extractAudioLocal(args[0], (args[1] as AudioFormat) || undefined));
} else if (cmd === "burn-subs") {
if (args.length < 2) die("Usage: burn-subs <path> <subs-path>");
printResult(burnSubs(args[0], args[1]));
} else if (cmd === "thumbnail") {
if (!args[0]) die("Usage: thumbnail <path> [timestamp]");
printResult(thumbnail(args[0], args[1] || undefined));
} else if (cmd === "loudnorm") {
if (!args[0]) die("Usage: loudnorm <path>");
printResult(loudnorm(args[0]));
} else if (cmd === "concat") {
if (args.length < 2) die("Usage: concat <path1> <path2> [path3...]");
const out = tmpPath("concat", extname(args[0]) || ".mp4");
printResult(concat(args, out));
} else if (cmd === "overlay") {
if (args.length < 2) die("Usage: overlay <base> <overlay> [position]");
printResult(overlay(args[0], args[1], (args[2] as OverlayPosition) || undefined));
} else {
die(`Unknown command: ${cmd}. Run 'npx tsx api.ts help' for usage.`);
}
}