Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ deterministic mp4 render. Human edits survive AI regeneration of the base.

## Commands

- `pnpm reframe render <scene.ts|.html> [--overlay f] [--theme brand.json] [-o out]` — mp4 into `out/`; `--theme` re-skins `token()` colors (a brand kit is a nested partial theme; also on `frame`/`player`)
- `pnpm reframe render <scene.ts|.html> [--overlay f] [--theme brand.json] [--supersample N] [-o out]` — mp4 into `out/`; `--theme` re-skins `token()` colors (a brand kit is a nested partial theme; also on `frame`/`player`); `--supersample 2` (alias `--ss`, clamp 1-4, default 1=off) renders N× via `deviceScaleFactor` and Lanczos-downscales to the scene size at encode — SSAA that smooths moving-text anti-alias shimmer (the affine 2.5D perspective re-skews text each frame). Opt-in → goldens byte-identical. Also on `frame`. `render-cli/frameLoop.ts` (`withPage` deviceScaleFactor + `downscalePng`), `encode.ts` (`-vf scale`)
- `pnpm reframe batch <scene.ts> <data.json|csv>` — one mp4 per row (row keys are overlay addresses like `nodes.<id>.<prop>` or `design.<token.path>` for a per-brand re-skin)
- `pnpm reframe logo <logo.svg | brand-slug> [--motion <preset>] [--energy n] [--seed n]` — animate a logo into a sting (published CLI command; `packages/render-cli/src/logoSting.ts`)
- `pnpm reframe labels <scene.ts>` — print the compiled event clock (every timeline label → exact seconds; the timing source for `audio.cues` and beat debugging)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ and `liquid-glass-nav.ts` (nav bar over a photo).

| command | what it does |
|---|---|
| `pnpm reframe render <scene.ts\|.json\|.html> [--overlay f]... [--theme brand.json] [-o out]` | deterministic mp4 (mode inferred from extension; output defaults to `out/`); `--theme` re-skins `token()` colors |
| `pnpm reframe render <scene.ts\|.json\|.html> [--overlay f]... [--theme brand.json] [--supersample N] [-o out]` | deterministic mp4 (output defaults to `out/`); `--theme` re-skins `token()` colors; `--supersample 2` renders 2× and downscales for crisp anti-aliasing (smooths moving text) |
| `pnpm reframe batch <scene.ts> <data.json\|csv> [-o dir] [--overlay f]...` | one mp4 per data row (rows = overlays; a `design.<token.path>` column = one mp4 per brand), parallel, with a per-row report |
| `pnpm reframe compile <scene.ts\|.json> [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium); `--json` returns `{ok, kind, issues}` |
| `pnpm reframe frame <scene.ts\|.json> [--t <sec>] [--overlay f]... [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux); `--overlay` previews edits |
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/edsl-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,11 @@ with `examples/scenes/sfx-showcase.ts` and the samples with `sample-showcase.ts`
- Overshoot pops are two steps: tween scale past 1 (`1.15`), then settle to 1.
- When a node enters by scaling from 0, start it at `opacity: 0` too and fade
in alongside — a scale-0 shape can still rasterize as a 1px dot at frame 0.
- Moving text under 3D rotation (a perspective card flip, a tilt) can shimmer
slightly — the 2.5D projection re-skews glyph edges each frame. Keep a node
rotationally still during the beats where text is read, and render with
`reframe render --supersample 2` (or `frame --supersample 2`) for crisp
anti-aliasing on any remaining motion.

## Worked example A — countdown (3, 2, 1, GO!)

Expand Down
12 changes: 11 additions & 1 deletion packages/render-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface Args {
framesDir?: string;
overlays: string[];
theme?: string;
/** Render at N× and downscale (SSAA) for crisp anti-aliasing; 1 = off (default). */
supersample?: number;
noAudio: boolean;
/** Composition: render only this scene id, standalone. */
scene?: string;
Expand Down Expand Up @@ -56,6 +58,7 @@ function parseArgs(argv: string[]): Args {
else if (a === "--frames-dir") args.framesDir = resolve(rest[++i]!);
else if (a === "--overlay") args.overlays.push(resolve(rest[++i]!));
else if (a === "--theme") args.theme = resolve(rest[++i]!);
else if (a === "--supersample" || a === "--ss") args.supersample = Math.max(1, Math.min(4, Math.floor(Number(rest[++i])) || 1));
else if (a === "--no-audio") args.noAudio = true;
else if (a === "--scene") args.scene = rest[++i]!;
else {
Expand All @@ -82,6 +85,7 @@ async function main() {
noAudio: args.noAudio,
...(args.fps !== undefined && { fps: args.fps }),
...(args.scene !== undefined && { onlyScene: args.scene }),
...(args.supersample !== undefined && { supersample: args.supersample }),
});
console.log(
args.scene !== undefined
Expand All @@ -94,6 +98,7 @@ async function main() {
const framesDir = args.framesDir ?? (await mkdtemp(join(tmpdir(), "reframe-frames-")));

let result;
let outSize = { width: 1920, height: 1080 };
let audioJob: { plan: import("@reframe/core").AudioPlan; videoOut: string } | null = null;
if (args.mode === "ir") {
let ir = loaded!.ir;
Expand All @@ -102,6 +107,7 @@ async function main() {
console.error(formatComposeReport(composed.report));
ir = composed.ir;
}
outSize = ir.size;
if (!args.noAudio) {
const plan = resolveAudioPlan(compileScene(ir));
if (plan) {
Expand All @@ -114,6 +120,7 @@ async function main() {
sceneDir: dirname(args.input),
...(args.fps !== undefined && { fps: args.fps }),
...(args.duration !== undefined && { duration: args.duration }),
...(args.supersample !== undefined && { supersample: args.supersample }),
});
} else {
if (args.duration === undefined || Number.isNaN(args.duration)) {
Expand All @@ -123,10 +130,13 @@ async function main() {
framesDir,
fps: args.fps ?? 30,
duration: args.duration,
...(args.supersample !== undefined && { supersample: args.supersample }),
});
}

await encodeMp4(result.framesDir, result.fps, audioJob ? audioJob.videoOut : args.out);
// supersampled frames are N×-sized → Lanczos-downscale to the scene size at encode
const downscale = args.supersample !== undefined && args.supersample > 1 ? outSize : undefined;
await encodeMp4(result.framesDir, result.fps, audioJob ? audioJob.videoOut : args.out, downscale ? { downscale } : {});
if (audioJob) {
await buildAudioTrack(audioJob.plan, args.input, audioJob.videoOut, args.out);
await rm(audioJob.videoOut, { force: true });
Expand Down
22 changes: 15 additions & 7 deletions packages/render-cli/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,18 @@ async function renderSceneVideo(
sceneDir: string,
fps: number | undefined,
out: string,
supersample?: number,
): Promise<{ fps: number; frameCount: number }> {
const framesDir = await mkdtemp(join(tmpdir(), "reframe-frames-"));
try {
const result = await captureIr(scene, { framesDir, sceneDir, ...(fps !== undefined && { fps }) });
await encodeMp4(result.framesDir, result.fps, out);
const result = await captureIr(scene, {
framesDir,
sceneDir,
...(fps !== undefined && { fps }),
...(supersample !== undefined && { supersample }),
});
const downscale = supersample !== undefined && supersample > 1 ? scene.size : undefined;
await encodeMp4(result.framesDir, result.fps, out, downscale ? { downscale } : {});
return { fps: result.fps, frameCount: result.frameCount };
} finally {
await rm(framesDir, { recursive: true, force: true });
Expand All @@ -59,15 +66,16 @@ async function renderStandaloneScene(
fps: number | undefined,
noAudio: boolean,
out: string,
supersample?: number,
): Promise<void> {
const plan = noAudio ? null : resolveAudioPlan(compileScene(scene));
if (plan) {
const videoOut = `${out}.video.mp4`;
await renderSceneVideo(scene, sceneDir, fps, videoOut);
await renderSceneVideo(scene, sceneDir, fps, videoOut, supersample);
await buildAudioTrack(plan, join(sceneDir, "scene"), videoOut, out);
await rm(videoOut, { force: true });
} else {
await renderSceneVideo(scene, sceneDir, fps, out);
await renderSceneVideo(scene, sceneDir, fps, out, supersample);
}
}

Expand Down Expand Up @@ -122,15 +130,15 @@ async function combineWithTransitions(

export async function renderComposition(
comp: CompositionIR,
opts: { compositionPath: string; out: string; fps?: number; noAudio: boolean; onlyScene?: string },
opts: { compositionPath: string; out: string; fps?: number; noAudio: boolean; onlyScene?: string; supersample?: number },
): Promise<{ duration: number; sceneCount: number }> {
const cc = compileComposition(comp);
const sceneDir = dirname(opts.compositionPath);

if (opts.onlyScene) {
const p = cc.scenes.find((s) => s.id === opts.onlyScene);
if (!p) throw new Error(`--scene "${opts.onlyScene}" not in composition; scenes: ${cc.scenes.map((s) => s.id).join(", ")}`);
await renderStandaloneScene(p.scene, sceneDir, opts.fps, opts.noAudio, opts.out);
await renderStandaloneScene(p.scene, sceneDir, opts.fps, opts.noAudio, opts.out, opts.supersample);
return { duration: p.duration, sceneCount: 1 };
}

Expand All @@ -139,7 +147,7 @@ export async function renderComposition(
const videos: { id: string; file: string; placement: ScenePlacement; fps: number }[] = [];
for (const p of cc.scenes) {
const file = join(tmp, `${sanitize(p.id)}.mp4`);
const { fps } = await renderSceneVideo(p.scene, sceneDir, opts.fps, file);
const { fps } = await renderSceneVideo(p.scene, sceneDir, opts.fps, file, opts.supersample);
videos.push({ id: p.id, file, placement: p, fps });
}
const combined = join(tmp, "combined.mp4");
Expand Down
11 changes: 10 additions & 1 deletion packages/render-cli/src/encode.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { spawn } from "node:child_process";

export async function encodeMp4(framesDir: string, fps: number, outFile: string): Promise<void> {
export async function encodeMp4(
framesDir: string,
fps: number,
outFile: string,
opts: { downscale?: { width: number; height: number } } = {},
): Promise<void> {
// When frames were rendered supersampled (N×), Lanczos-downscale them to the scene
// size here — the downscale IS the anti-aliasing (smooths moving-text edges).
const ds = opts.downscale;
const args = [
"-y",
"-framerate",
String(fps),
"-i",
`${framesDir}/%05d.png`,
...(ds ? ["-vf", `scale=${ds.width}:${ds.height}:flags=lanczos`] : []),
"-c:v",
"libx264",
"-preset",
Expand Down
9 changes: 6 additions & 3 deletions packages/render-cli/src/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { formatComposeReport } from "@reframe/core";
import { renderFrameAt } from "./frameLoop.js";
import { renderFrameAt, downscalePng } from "./frameLoop.js";
import { loadModule } from "./loadScene.js";
import { applyOverlays } from "./overlay.js";

Expand All @@ -25,12 +25,14 @@ async function main(): Promise<void> {
let out = "";
const overlays: string[] = [];
let theme: string | undefined;
let supersample = 1;
for (let i = 1; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--t") t = Number(argv[++i]);
else if (a === "-o") out = argv[++i]!;
else if (a === "--overlay") overlays.push(resolve(argv[++i]!));
else if (a === "--theme") theme = resolve(argv[++i]!);
else if (a === "--supersample" || a === "--ss") supersample = Math.max(1, Math.min(4, Math.floor(Number(argv[++i])) || 1));
else {
console.error(`unknown argument: ${a}`);
process.exit(2);
Expand All @@ -53,9 +55,10 @@ async function main(): Promise<void> {
console.error(formatComposeReport(composed.report));
ir = composed.ir;
}
const buf = await renderFrameAt(ir, t, { sceneDir: dirname(scenePath) });
const buf = await renderFrameAt(ir, t, { sceneDir: dirname(scenePath), supersample });
const finalBuf = supersample > 1 ? downscalePng(buf, ir.size.width, ir.size.height) : buf;
const outPath = out ? resolve(out) : resolve(`${loaded.ir.id}.png`);
await writeFile(outPath, buf);
await writeFile(outPath, finalBuf);
console.log(outPath);
}

Expand Down
31 changes: 25 additions & 6 deletions packages/render-cli/src/frameLoop.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
Expand Down Expand Up @@ -29,18 +30,35 @@ const framePath = (dir: string, i: number) => join(dir, `${String(i).padStart(5,
async function withPage<T>(
size: { width: number; height: number },
fn: (page: Page) => Promise<T>,
opts: { deviceScaleFactor?: number } = {},
): Promise<T> {
const browser = await chromium.launch({
args: ["--force-color-profile=srgb", "--font-render-hinting=none"],
});
try {
const page = await browser.newPage({ viewport: size, deviceScaleFactor: 1 });
// deviceScaleFactor N renders the page at N× physical pixels (supersampling);
// the captured PNG comes out N×-sized and is downscaled to `size` at encode/write.
const page = await browser.newPage({ viewport: size, deviceScaleFactor: opts.deviceScaleFactor ?? 1 });
return await fn(page);
} finally {
await browser.close();
}
}

/** Lanczos-downscale a PNG buffer to `width`×`height` via ffmpeg (for supersampled single frames). */
export function downscalePng(png: Buffer, width: number, height: number): Buffer {
const res = spawnSync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-i", "pipe:0",
"-vf", `scale=${width}:${height}:flags=lanczos`, "-f", "image2pipe", "-c:v", "png", "pipe:1"],
{ input: png, maxBuffer: 512 * 1024 * 1024 },
);
if (res.status !== 0) {
throw new Error(`ffmpeg downscale failed: ${res.stderr?.toString() ?? res.error?.message ?? "unknown"}`);
}
return res.stdout;
}

let bundleCache: string | null = null;
async function browserBundle(): Promise<string> {
if (bundleCache) return bundleCache;
Expand Down Expand Up @@ -68,7 +86,7 @@ async function browserBundle(): Promise<string> {
/** Render a reframe IR scene: evaluate(t) per frame inside the page, pull PNGs out. */
export async function captureIr(
ir: SceneIR,
opts: { fps?: number; duration?: number; framesDir: string; sceneDir?: string },
opts: { fps?: number; duration?: number; framesDir: string; sceneDir?: string; supersample?: number },
): Promise<CaptureResult> {
await mkdir(opts.framesDir, { recursive: true });
const sceneDir = opts.sceneDir ?? process.cwd();
Expand Down Expand Up @@ -97,11 +115,11 @@ export async function captureIr(
await writeFile(framePath(opts.framesDir, f), Buffer.from(dataUrl.slice(22), "base64"));
}
return { framesDir: opts.framesDir, frameCount, fps };
});
}, opts.supersample !== undefined ? { deviceScaleFactor: opts.supersample } : {});
}

/** Render ONE frame of an IR scene at scene-time `t` → PNG buffer (for the `diff` tool). */
export async function renderFrameAt(ir: SceneIR, t: number, opts: { sceneDir?: string } = {}): Promise<Buffer> {
export async function renderFrameAt(ir: SceneIR, t: number, opts: { sceneDir?: string; supersample?: number } = {}): Promise<Buffer> {
const sceneDir = opts.sceneDir ?? process.cwd();
const assets = await buildImageAssets(ir, sceneDir);
const { fps, duration } = resolveTiming(ir, {});
Expand All @@ -118,7 +136,7 @@ export async function renderFrameAt(ir: SceneIR, t: number, opts: { sceneDir?: s
);
const dataUrl = await page.evaluate((tt) => window.__reframe.renderFrame(tt), t);
return Buffer.from(dataUrl.slice(22), "base64");
});
}, opts.supersample !== undefined ? { deviceScaleFactor: opts.supersample } : {});
}

/**
Expand All @@ -134,6 +152,7 @@ export async function captureHtml(
framesDir: string;
width?: number;
height?: number;
supersample?: number;
},
): Promise<CaptureResult> {
await mkdir(opts.framesDir, { recursive: true });
Expand All @@ -158,5 +177,5 @@ export async function captureHtml(
else await page.screenshot({ path, animations: "allow" });
}
return { framesDir: opts.framesDir, frameCount, fps: opts.fps };
});
}, opts.supersample !== undefined ? { deviceScaleFactor: opts.supersample } : {});
}
4 changes: 2 additions & 2 deletions packages/render-cli/src/reframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const PLUGIN_DIR = PACKAGED ? ROOT : join(ROOT, "plugin");
const USAGE = `reframe — declarative motion graphics

usage:
${CMD} render <scene.ts|.json|.html> [--overlay edits.json]... [--theme brand.json] [-o out.mp4] [--fps N] [--duration S] [--no-audio]
${CMD} render <scene.ts|.json|.html> [--overlay edits.json]... [--theme brand.json] [--supersample 2] [-o out.mp4] [--fps N] [--duration S] [--no-audio]
${CMD} batch <scene.ts> <data.json|csv> [-o outDir] [--overlay base.json]... [--concurrency N] [--fps N]
${CMD} logo <logo.svg|brand-slug> ["Name"] [--motion <preset>] [--energy 0..1] [--seed N] [-o out.mp4]
animate a logo into a sting (presets: draw-bloom, punch-in,
Expand All @@ -120,7 +120,7 @@ usage:
compose overlay(s) onto a scene → composed SceneIR (no render; feed to player/frame for live preview)
${CMD} compile <scene.ts|.json> [-o out.json] [--stdin] [--code "<src>"] [--json]
bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium)
${CMD} frame <scene.ts|.json> [--t <sec>] [--overlay <doc.json>]... [--theme brand.json] [-o out.png] render ONE frame at time t to a PNG (no mp4; --overlay/--theme preview edits)
${CMD} frame <scene.ts|.json> [--t <sec>] [--overlay <doc.json>]... [--theme brand.json] [--supersample 2] [-o out.png] render ONE frame at time t to a PNG (no mp4; --supersample N = N× SSAA for crisp text)
${CMD} skill [--path] print the authoring skill (SKILL.md) for an agent; --path prints the plugin dir to load
${CMD} motion <mp4|framesDir> motion-profile a rendered clip
${CMD} trace <ref.mp4> [--apply scene.ts] extract a video's motion structure → MotionSketch / timeline
Expand Down
Loading