diff --git a/AGENTS.md b/AGENTS.md index 9edae5f..6e8a96e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ deterministic mp4 render. Human edits survive AI regeneration of the base. ## Commands -- `pnpm reframe render [--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 [--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 ` — one mp4 per row (row keys are overlay addresses like `nodes..` or `design.` for a per-brand re-skin) - `pnpm reframe logo [--motion ] [--energy n] [--seed n]` — animate a logo into a sting (published CLI command; `packages/render-cli/src/logoSting.ts`) - `pnpm reframe labels ` — print the compiled event clock (every timeline label → exact seconds; the timing source for `audio.cues` and beat debugging) diff --git a/README.md b/README.md index cf4e538..e772225 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ and `liquid-glass-nav.ts` (nav bar over a photo). | command | what it does | |---|---| -| `pnpm reframe render [--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 [--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 [-o dir] [--overlay f]...` | one mp4 per data row (rows = overlays; a `design.` column = one mp4 per brand), parallel, with a per-row report | | `pnpm reframe compile [-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 [--t ] [--overlay f]... [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux); `--overlay` previews edits | diff --git a/docs/guides/edsl-guide.md b/docs/guides/edsl-guide.md index 9a5a62f..0c55607 100644 --- a/docs/guides/edsl-guide.md +++ b/docs/guides/edsl-guide.md @@ -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!) diff --git a/packages/render-cli/src/cli.ts b/packages/render-cli/src/cli.ts index 194a60a..4cd952c 100755 --- a/packages/render-cli/src/cli.ts +++ b/packages/render-cli/src/cli.ts @@ -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; @@ -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 { @@ -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 @@ -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; @@ -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) { @@ -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)) { @@ -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 }); diff --git a/packages/render-cli/src/composition.ts b/packages/render-cli/src/composition.ts index 81b3cd4..431d921 100644 --- a/packages/render-cli/src/composition.ts +++ b/packages/render-cli/src/composition.ts @@ -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 }); @@ -59,15 +66,16 @@ async function renderStandaloneScene( fps: number | undefined, noAudio: boolean, out: string, + supersample?: number, ): Promise { 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); } } @@ -122,7 +130,7 @@ 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); @@ -130,7 +138,7 @@ export async function renderComposition( 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 }; } @@ -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"); diff --git a/packages/render-cli/src/encode.ts b/packages/render-cli/src/encode.ts index 233a1d7..544dbe2 100644 --- a/packages/render-cli/src/encode.ts +++ b/packages/render-cli/src/encode.ts @@ -1,12 +1,21 @@ import { spawn } from "node:child_process"; -export async function encodeMp4(framesDir: string, fps: number, outFile: string): Promise { +export async function encodeMp4( + framesDir: string, + fps: number, + outFile: string, + opts: { downscale?: { width: number; height: number } } = {}, +): Promise { + // 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", diff --git a/packages/render-cli/src/frame.ts b/packages/render-cli/src/frame.ts index 61772dd..0d227d4 100644 --- a/packages/render-cli/src/frame.ts +++ b/packages/render-cli/src/frame.ts @@ -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"; @@ -25,12 +25,14 @@ async function main(): Promise { 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); @@ -53,9 +55,10 @@ async function main(): Promise { 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); } diff --git a/packages/render-cli/src/frameLoop.ts b/packages/render-cli/src/frameLoop.ts index 5d7fdd6..ab66d62 100644 --- a/packages/render-cli/src/frameLoop.ts +++ b/packages/render-cli/src/frameLoop.ts @@ -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"; @@ -29,18 +30,35 @@ const framePath = (dir: string, i: number) => join(dir, `${String(i).padStart(5, async function withPage( size: { width: number; height: number }, fn: (page: Page) => Promise, + opts: { deviceScaleFactor?: number } = {}, ): Promise { 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 { if (bundleCache) return bundleCache; @@ -68,7 +86,7 @@ async function browserBundle(): Promise { /** 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 { await mkdir(opts.framesDir, { recursive: true }); const sceneDir = opts.sceneDir ?? process.cwd(); @@ -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 { +export async function renderFrameAt(ir: SceneIR, t: number, opts: { sceneDir?: string; supersample?: number } = {}): Promise { const sceneDir = opts.sceneDir ?? process.cwd(); const assets = await buildImageAssets(ir, sceneDir); const { fps, duration } = resolveTiming(ir, {}); @@ -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 } : {}); } /** @@ -134,6 +152,7 @@ export async function captureHtml( framesDir: string; width?: number; height?: number; + supersample?: number; }, ): Promise { await mkdir(opts.framesDir, { recursive: true }); @@ -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 } : {}); } diff --git a/packages/render-cli/src/reframe.ts b/packages/render-cli/src/reframe.ts index 6835e5b..fb83a3a 100644 --- a/packages/render-cli/src/reframe.ts +++ b/packages/render-cli/src/reframe.ts @@ -97,7 +97,7 @@ const PLUGIN_DIR = PACKAGED ? ROOT : join(ROOT, "plugin"); const USAGE = `reframe — declarative motion graphics usage: - ${CMD} render [--overlay edits.json]... [--theme brand.json] [-o out.mp4] [--fps N] [--duration S] [--no-audio] + ${CMD} render [--overlay edits.json]... [--theme brand.json] [--supersample 2] [-o out.mp4] [--fps N] [--duration S] [--no-audio] ${CMD} batch [-o outDir] [--overlay base.json]... [--concurrency N] [--fps N] ${CMD} logo ["Name"] [--motion ] [--energy 0..1] [--seed N] [-o out.mp4] animate a logo into a sting (presets: draw-bloom, punch-in, @@ -120,7 +120,7 @@ usage: compose overlay(s) onto a scene → composed SceneIR (no render; feed to player/frame for live preview) ${CMD} compile [-o out.json] [--stdin] [--code ""] [--json] bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium) - ${CMD} frame [--t ] [--overlay ]... [--theme brand.json] [-o out.png] render ONE frame at time t to a PNG (no mp4; --overlay/--theme preview edits) + ${CMD} frame [--t ] [--overlay ]... [--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 motion-profile a rendered clip ${CMD} trace [--apply scene.ts] extract a video's motion structure → MotionSketch / timeline