diff --git a/packages/cli/src/utils/compositionFps.test.ts b/packages/cli/src/utils/compositionFps.test.ts index 7c2a5b37bc..167cc00e69 100644 --- a/packages/cli/src/utils/compositionFps.test.ts +++ b/packages/cli/src/utils/compositionFps.test.ts @@ -12,6 +12,43 @@ describe("readCompositionFps", () => { expect(readCompositionFps(html)).toBe("24"); }); + it.each(["DATA-FPS", "Data-FpS"])("reads an HTML %s attribute", (attribute) => { + const html = wrap(`
x
`); + expect(readCompositionFps(html)).toBe("24"); + }); + + it("prefers an explicit root with mixed-case HTML attribute names", () => { + const html = wrap( + '
' + + '
x
', + ); + expect(readCompositionFps(html)).toBe("24"); + }); + + it("reads the outermost composition with mixed-case HTML attribute names", () => { + const html = wrap( + '
x
', + ); + expect(readCompositionFps(html)).toBe("48"); + }); + + it.each([ + 'DATA-FPS="24" data-fps="60"', + 'Data-FpS="24" DATA-FPS="60"', + 'data-fps="24" DATA-FPS="60"', + ])("keeps the first duplicate HTML attribute in %s", (attributes) => { + const html = wrap(`
x
`); + expect(readCompositionFps(html)).toBe("24"); + }); + + it("keeps the first duplicate data-root value when selecting the root", () => { + const html = wrap( + '
' + + '
x
', + ); + expect(readCompositionFps(html)).toBe("24"); + }); + it("reads data-fps from the outermost composition when no data-root is marked", () => { const html = wrap( '
x
', @@ -77,6 +114,16 @@ describe("defaultCloudFps", () => { expect(String(warn.mock.calls[0]?.[0])).toContain(`data-fps="${fps}"`); }); + it("reads mixed-case HTML attributes before validating the cloud fps", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const dir = makeProject( + '
x
', + ); + + expect(defaultCloudFps(dir)).toBe(24); + expect(warn).not.toHaveBeenCalled(); + }); + it("uses 30 without warning when index.html has no data-fps", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect( diff --git a/packages/cli/src/utils/compositionFps.ts b/packages/cli/src/utils/compositionFps.ts index f2af7f8c9c..1df7d199f3 100644 --- a/packages/cli/src/utils/compositionFps.ts +++ b/packages/cli/src/utils/compositionFps.ts @@ -4,6 +4,14 @@ import { DISTRIBUTED_FPS, type DistributedFps, isDistributedFps } from "@hyperfr import { parseHTML } from "linkedom"; import { c } from "../ui/colors.js"; +function getHtmlAttribute(element: Element, name: string): string | null { + // linkedom preserves spelling; HTML names match without case and keep the first duplicate. + return ( + Array.from(element.attributes).find((attribute) => attribute.name.toLowerCase() === name) + ?.value ?? null + ); +} + /** * 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 @@ -24,15 +32,14 @@ export function readCompositionFps(html: string): string | null { return null; } - const explicitRoot = doc.querySelector('[data-composition-id][data-root="true"]'); + const compositions = Array.from(doc.querySelectorAll("*")).filter( + (element) => getHtmlAttribute(element, "data-composition-id") !== null, + ); const root = - explicitRoot ?? - Array.from(doc.querySelectorAll("[data-composition-id]")).find( - (el) => !el.parentElement?.closest("[data-composition-id]"), - ) ?? - null; + compositions.find((element) => getHtmlAttribute(element, "data-root") === "true") ?? + compositions[0]; - const raw = root?.getAttribute("data-fps")?.trim(); + const raw = root ? getHtmlAttribute(root, "data-fps")?.trim() : undefined; return raw ? raw : null; } diff --git a/packages/cli/src/utils/renderArgs.test.ts b/packages/cli/src/utils/renderArgs.test.ts index 5ed0bb54d2..84fc1ca0ad 100644 --- a/packages/cli/src/utils/renderArgs.test.ts +++ b/packages/cli/src/utils/renderArgs.test.ts @@ -332,6 +332,21 @@ describe("resolveDefaultFpsArg", () => { } }); + it("uses an uppercase HTML data-fps declaration when --fps is omitted", () => { + const project = makeProject(); + try { + writeFileSync( + project.indexPath, + '
x
', + ); + + expect(resolveDefaultFpsArg(undefined, project.dir, project.indexPath, undefined)).toBe("24"); + expect(resolveDefaultFpsArg("60", project.dir, project.indexPath, undefined)).toBe("60"); + } finally { + project.cleanup(); + } + }); + it("keeps an explicit --fps value ahead of any composition default", () => { const project = makeProject(); try {