Files
hyperframes/packages/cli/src/utils/compositionFps.test.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

108 lines
3.8 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readAllowedCompositionFpsFromDir, readCompositionFps } from "./compositionFps.js";
const wrap = (body: string) => `<!DOCTYPE html><html><body>${body}</body></html>`;
describe("readCompositionFps", () => {
it("reads data-fps from the explicit data-root composition element", () => {
const html = wrap('<div data-composition-id="root" data-root="true" data-fps="24">x</div>');
expect(readCompositionFps(html)).toBe("24");
});
it("reads data-fps from the outermost composition when no data-root is marked", () => {
const html = wrap(
'<div data-composition-id="root" data-fps="48"><div data-composition-id="child" data-fps="12">x</div></div>',
);
expect(readCompositionFps(html)).toBe("48");
});
it("preserves a fractional rate verbatim for parseFps to validate", () => {
const html = wrap(
'<div data-composition-id="root" data-root="true" data-fps="30000/1001">x</div>',
);
expect(readCompositionFps(html)).toBe("30000/1001");
});
it("returns null when the root has no data-fps", () => {
expect(readCompositionFps(wrap('<div data-composition-id="root">x</div>'))).toBeNull();
});
it("returns null when there is no composition root", () => {
expect(readCompositionFps(wrap("<div>plain</div>"))).toBeNull();
});
it("returns null for a blank data-fps", () => {
expect(
readCompositionFps(wrap('<div data-composition-id="root" data-fps=" ">x</div>')),
).toBeNull();
});
});
describe("readAllowedCompositionFpsFromDir", () => {
const projectDirs: string[] = [];
const allowedCloudFps = [24, 30, 60] as const;
afterEach(() => {
for (const dir of projectDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeProject(indexBody: string): string {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-composition-fps-"));
projectDirs.push(dir);
writeFileSync(join(dir, "index.html"), wrap(indexBody));
return dir;
}
it("uses cloud-allowed data-fps=60 as the default", () => {
const dir = makeProject(
'<div data-composition-id="root" data-root="true" data-fps="60">x</div>',
);
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBe(60);
});
it("uses cloud-allowed data-fps=24 as the default", () => {
const dir = makeProject(
'<div data-composition-id="root" data-root="true" data-fps="24">x</div>',
);
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBe(24);
});
it("returns null for data-fps=48 so cloud callers keep their ?? 30 fallback", () => {
const dir = makeProject(
'<div data-composition-id="root" data-root="true" data-fps="48">x</div>',
);
const declared = readAllowedCompositionFpsFromDir(dir, allowedCloudFps);
expect(declared).toBeNull();
expect(declared ?? 30).toBe(30);
});
it("returns null for fractional data-fps because cloud fps must be an integer", () => {
const dir = makeProject(
'<div data-composition-id="root" data-root="true" data-fps="30000/1001">x</div>',
);
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
});
it("returns null when index.html has no data-fps", () => {
const dir = makeProject('<div data-composition-id="root" data-root="true">x</div>');
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
});
it("returns null when index.html cannot be read", () => {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-composition-fps-missing-"));
projectDirs.push(dir);
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
});
});