From 8a84bedd38b2c002f379323b8b01251c24e77cb4 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 04:11:08 +0000 Subject: [PATCH] fix(cli): read info metadata from the root composition (#5299) --- packages/cli/src/commands/info.test.ts | 80 +++++++++++++++++++++++++- packages/cli/src/commands/info.ts | 36 ++++++------ 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/info.test.ts b/packages/cli/src/commands/info.test.ts index 1386216100..dca6a2c1cf 100644 --- a/packages/cli/src/commands/info.test.ts +++ b/packages/cli/src/commands/info.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { orientation, durationFromHtml } from "./info.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import info, { orientation, durationFromHtml } from "./info.js"; describe("orientation", () => { it("is landscape when width > height", () => { @@ -31,3 +34,76 @@ describe("durationFromHtml", () => { expect(durationFromHtml(html, 5)).toBe(5); }); }); + +describe("info", () => { + it.each([ + { + name: "quoted attributes", + html: '
', + }, + { + name: "whitespace around attribute assignments", + html: '
', + }, + { + name: "unquoted attributes", + html: "
", + }, + { + name: "commented composition before the root", + html: ` +
`, + }, + { + name: "composition markup inside a script string", + html: ` +
`, + }, + { + name: "explicit root after another composition", + html: `
+
`, + }, + ])("reports the root metadata with $name", async ({ html }) => { + const dir = mkdtempSync(join(tmpdir(), "hf-info-")); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + writeFileSync(join(dir, "index.html"), html); + await info.run?.({ args: { _: [], dir, json: true }, rawArgs: [], cmd: info }); + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + resolution: "portrait", + width: 64, + height: 96, + duration: 0.2, + }); + } finally { + log.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps the inferred resolution and timeline duration when root metadata is absent", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-info-")); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + writeFileSync( + join(dir, "index.html"), + `
+
+
`, + ); + await info.run?.({ args: { _: [], dir, json: true }, rawArgs: [], cmd: info }); + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + resolution: "landscape", + width: 1920, + height: 1080, + duration: 3, + }); + } finally { + log.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index f57d40f2a1..d57661b976 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -8,6 +8,11 @@ export const examples: Example[] = [ ]; import { join } from "node:path"; import { parseHtml, CANVAS_DIMENSIONS } from "@hyperframes/core"; +import { + findRootCompositionElement, + readCompositionSize, +} from "@hyperframes/core/runtime/composition-length"; +import { readDataDurationSeconds } from "@hyperframes/parsers/media-duration"; import { c } from "../ui/colors.js"; import { formatBytes, label } from "../ui/format.js"; import { ensureDOMParser } from "../utils/dom.js"; @@ -26,11 +31,16 @@ export function orientation(width: number, height: number): "landscape" | "portr * fall back to the computed timeline end. */ export function durationFromHtml(html: string, fallback: number): number { - const match = - html.match(/data-composition-id[^>]*data-duration=["']([\d.]+)["']/) || - html.match(/data-duration=["']([\d.]+)["'][^>]*data-composition-id/); - const value = match?.[1] ? parseFloat(match[1]) : NaN; - return Number.isFinite(value) ? value : fallback; + return durationFromRoot(rootFromHtml(html), fallback); +} + +function rootFromHtml(html: string): HTMLElement | null { + ensureDOMParser(); + return findRootCompositionElement(new DOMParser().parseFromString(html, "text/html")); +} + +function durationFromRoot(root: Element | null, fallback: number): number { + return readDataDurationSeconds((name) => root?.getAttribute(name)) ?? fallback; } function totalSize(dir: string): number { @@ -56,7 +66,7 @@ export default defineCommand({ const project = resolveProject(args.dir); const html = readFileSync(project.indexPath, "utf-8"); - ensureDOMParser(); + const root = rootFromHtml(html); const parsed = parseHtml(html); const tracks = new Set(parsed.elements.map((el) => el.zIndex)); @@ -64,18 +74,12 @@ export default defineCommand({ (max, el) => Math.max(max, el.startTime + el.duration), 0, ); - // Read actual dimensions from root composition element - const widthMatch = - html.match(/data-composition-id[^>]*data-width=["'](\d+)["']/) || - html.match(/data-width=["'](\d+)["'][^>]*data-composition-id/); - const heightMatch = - html.match(/data-composition-id[^>]*data-height=["'](\d+)["']/) || - html.match(/data-height=["'](\d+)["'][^>]*data-composition-id/); + const dimensions = root ? readCompositionSize(root) : null; const fallback = CANVAS_DIMENSIONS[parsed.resolution]; - const width = widthMatch?.[1] ? parseInt(widthMatch[1], 10) : fallback.width; - const height = heightMatch?.[1] ? parseInt(heightMatch[1], 10) : fallback.height; + const width = dimensions?.width ?? fallback.width; + const height = dimensions?.height ?? fallback.height; const resolution = `${width}x${height}`; - const duration = durationFromHtml(html, maxEnd); + const duration = durationFromRoot(root, maxEnd); const size = totalSize(project.dir); const typeCounts: Record = {};