Files
hyperframes/packages/cli/src/utils/compositionFps.ts
T
Miguel Ángel de27b46680 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.
2026-07-07 17:10:10 -04:00

60 lines
2.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseHTML } from "linkedom";
/**
* Read a composition's declared frame rate from its root element's `data-fps`
* attribute — the same attribute the runtime honors (core/runtime/init.ts) — so
* `hyperframes render` can default to it instead of a hard-coded 30 when `--fps`
* is not passed. Returns the raw attribute string (for the caller to validate
* via `parseFps`, which supports fractional rates like `30000/1001`), or `null`
* when no root `data-fps` is present.
*
* Root resolution mirrors the runtime: prefer an explicit
* `[data-composition-id][data-root="true"]`, else the outermost
* `[data-composition-id]` (one with no `[data-composition-id]` ancestor).
*/
export function readCompositionFps(html: string): string | null {
let doc: Document;
try {
doc = parseHTML(html).document as unknown as Document;
} catch {
return null;
}
const explicitRoot = doc.querySelector('[data-composition-id][data-root="true"]');
const root =
explicitRoot ??
Array.from(doc.querySelectorAll("[data-composition-id]")).find(
(el) => !el.parentElement?.closest("[data-composition-id]"),
) ??
null;
const raw = root?.getAttribute("data-fps")?.trim();
return raw ? raw : null;
}
/**
* Cloud render backends (Lambda, Cloud Run) accept only an integer fps
* from a small fixed allowed set (currently {24, 30, 60}) — unlike local
* `render`, they can't take an arbitrary/fractional data-fps. Reads
* `<projectDir>/index.html` and returns its declared data-fps as a number
* ONLY when it parses to an integer AND is a member of `allowed`;
* otherwise `null` so the caller keeps its own existing default (30).
*/
export function readAllowedCompositionFpsFromDir(
projectDir: string,
allowed: readonly number[],
): number | null {
let html: string;
try {
html = readFileSync(join(projectDir, "index.html"), "utf8");
} catch {
return null;
}
const raw = readCompositionFps(html);
if (raw == null) return null;
const n = Number(raw);
return Number.isInteger(n) && allowed.includes(n) ? n : null;
}