fix(cli): default render fps to the composition's data-fps

* fix(cli): default render fps to the composition's data-fps

hyperframes render hard-coded fps to 30 when --fps was omitted, ignoring a
data-fps declared on the composition root — so a composition authored at
data-fps="24" silently rendered at 30fps unless the user knew to pass --fps 24.
The runtime already honors data-fps; the CLI now matches it.

Precedence: explicit --fps > composition root data-fps > 30. New pure
readCompositionFps() extracts the root data-fps via linkedom (mirrors the
runtime's root resolution: [data-composition-id][data-root=true], else the
outermost [data-composition-id]); render validates it through parseFps and
falls back to 30 on an absent/invalid value. Unit-tested.

* fix(cli): honor composition data-fps on cloud renders and --composition targets

The local render command read data-fps from project.dir/index.html even when
--composition rendered a different file, and the lambda/cloudrun render paths
ignored data-fps entirely (hardcoded ?? 30). Both are the same silently-wrong-
fps bug on other render entry points:
- render.ts resolves the entry file first, then reads data-fps from the file
  actually being rendered (falling back to index.html).
- lambda render/render-batch and cloudrun render/render-batch default fps from
  the composition's data-fps, accepted only when it is one of the cloud-allowed
  values {24,30,60}, else the existing 30 default. Explicit --fps still wins.

* fix(cli): drop citty fps default so data-fps resolution actually runs

The fps arg had default: "30", so citty set args.fps="30" on omission and
resolveDefaultFpsArg short-circuited (explicitFps never null) — reverting the
command to always-30 and making the whole data-fps feature a no-op (caught in
review). Remove the arg default; the "30" fallback already lives at
parseFps(fpsArg ?? "30"). Adds a regression guard asserting the arg has no
default.

* test(cli): read citty args through a plain record in the fps-default guard

The regression guard accessed cmd.args.fps directly, but citty types args as
Resolvable<ArgsDef> so .fps failed typecheck in CI. Read it through a plain
record cast.
This commit is contained in:
Miguel Ángel
2026-07-07 17:10:10 -04:00
committed by GitHub
parent 92f3116dee
commit de27b46680
8 changed files with 315 additions and 15 deletions
+21 -8
View File
@@ -10,6 +10,7 @@ import {
parseGifLoopArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
} from "../utils/renderArgs.js";
export const examples: Example[] = [
@@ -171,8 +172,12 @@ export default defineCommand({
description:
"Frame rate. Accepts integer (24, 25, 30, 50, 60, 120, 240) or " +
"ffmpeg-style rational (30000/1001 for NTSC 29.97, 24000/1001 for " +
"23.976, 60000/1001 for 59.94). Range 1-240.",
default: "30",
"23.976, 60000/1001 for 59.94). Range 1-240. " +
"Defaults to the composition's root data-fps, else 30.",
// No `default` here on purpose: citty would set args.fps="30" on
// omission, which would make explicitFps always non-null and short-
// circuit the data-fps resolution below (resolveDefaultFpsArg). The
// "30" fallback lives at the parseFps(fpsArg ?? "30") call instead.
},
quality: {
type: "string",
@@ -382,15 +387,24 @@ export default defineCommand({
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
// ── Resolve composition entry file ─────────────────────────────────────
// Needed early: fps default below must read the actual render target, not
// always index.html.
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
// ── Validate fps ───────────────────────────────────────────────────────
// Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
// The whitelist-based validator was replaced with a sane numeric range so
// legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without
// CLI gymnastics. The exact rational survives end-to-end into FFmpeg's
// `-r` / `-framerate` flags via `fpsToFfmpegArg`.
const fpsParse = parseFps(args.fps ?? "30");
// Precedence: explicit --fps, else the composition's root data-fps, else 30.
// Honoring data-fps matches the runtime — render used to silently force 30
// even when the composition declared e.g. data-fps="24".
const fpsArg = resolveDefaultFpsArg(args.fps, project.dir, project.indexPath, entryFile);
const fpsParse = parseFps(fpsArg ?? "30");
if (!fpsParse.ok) {
errorBox("Invalid fps", formatFpsParseError(args.fps ?? "30", fpsParse.reason));
errorBox("Invalid fps", formatFpsParseError(fpsArg ?? "30", fpsParse.reason));
process.exit(1);
}
let fps: Fps = fpsParse.value;
@@ -659,12 +673,11 @@ export default defineCommand({
console.log(c.warn(" GIF output is capped at 30fps. Use --fps 15 for smaller files."));
}
// ── Validate browser-timeout (seconds) and composition entry file ────
// Both validators live in `utils/renderArgs.ts` so the parse/reject
// ── Validate browser-timeout (seconds) ───────────────────────────────
// This validator lives in `utils/renderArgs.ts` so the parse/reject
// branches are unit-testable without `process.exit`. See issue #1199
// for the original EISDIR / silent-timeout-0 footguns this guards.
// for the original silent-timeout-0 footgun this guards.
const pageNavigationTimeoutMs = resolveBrowserTimeoutMsArg(args["browser-timeout"]);
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
// ── Preflight batch rows before browser/lint work ────────────────────
let batchModule: typeof import("./batchRender.js") | undefined;