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(
+ '',
+ );
+ 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(
'',
@@ -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 {