diff --git a/packages/cli/src/commands/compositions.test.ts b/packages/cli/src/commands/compositions.test.ts
index 50c4456ff1..32c30caa1c 100644
--- a/packages/cli/src/commands/compositions.test.ts
+++ b/packages/cli/src/commands/compositions.test.ts
@@ -1,6 +1,46 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { describe, expect, it, beforeEach } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
-import { parseSubComposition } from "./compositions.js";
+import { parseCompositions, parseSubComposition } from "./compositions.js";
+
+describe("parseCompositions", () => {
+ beforeEach(() => {
+ ensureDOMParser();
+ });
+
+ it("resolves relative sub-composition starts when computing host duration", () => {
+ const baseDir = mkdtempSync(join(tmpdir(), "hyperframes-compositions-"));
+
+ try {
+ const compositionsDir = join(baseDir, "compositions");
+ mkdirSync(compositionsDir);
+
+ const subCompositionHtml = `
+
+
+`;
+ writeFileSync(join(compositionsDir, "scene.html"), subCompositionHtml);
+
+ const html = `
+
`;
+
+ const host = parseCompositions(html, baseDir).find(
+ (composition) => composition.id === "host",
+ );
+
+ expect(host?.duration).toBe(6);
+ } finally {
+ rmSync(baseDir, { recursive: true, force: true });
+ }
+ });
+});
describe("parseSubComposition", () => {
beforeEach(() => {
diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts
index b31645ee5a..f962640dc7 100644
--- a/packages/cli/src/commands/compositions.ts
+++ b/packages/cli/src/commands/compositions.ts
@@ -1,4 +1,5 @@
import { defineCommand } from "citty";
+import { parseNumeric, parseStartExpression } from "@hyperframes/core";
import type { Example } from "./_examples.js";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
@@ -45,12 +46,78 @@ function estimateDurationFromScripts(root: ParentNode): number {
return duration;
}
-function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
+function findReferenceTargetEl(doc: Document, refId: string): Element | null {
+ return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
+}
+
+function resolveStart(
+ doc: Document,
+ el: Element,
+ startCache: Map,
+ visiting: Set,
+): number {
+ const cached = startCache.get(el);
+ if (cached !== undefined) return cached;
+ if (visiting.has(el)) return 0;
+ visiting.add(el);
+
+ try {
+ const expression = parseStartExpression(el.getAttribute("data-start"));
+ if (!expression) {
+ startCache.set(el, 0);
+ return 0;
+ }
+
+ if (expression.kind === "absolute") {
+ const value = Math.max(0, expression.value);
+ startCache.set(el, value);
+ return value;
+ }
+
+ const target = findReferenceTargetEl(doc, expression.refId);
+ if (!target) {
+ startCache.set(el, 0);
+ return 0;
+ }
+
+ const targetStart = resolveStart(doc, target, startCache, visiting);
+ const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting);
+ const resolved =
+ targetDuration != null && targetDuration > 0
+ ? Math.max(0, targetStart + targetDuration + expression.offset)
+ : Math.max(0, targetStart + expression.offset);
+ startCache.set(el, resolved);
+ return resolved;
+ } finally {
+ visiting.delete(el);
+ }
+}
+
+function resolveReferencedDuration(
+ doc: Document,
+ el: Element,
+ startCache: Map,
+ visiting: Set,
+): number | null {
+ const durationAttr = parseNumeric(el.getAttribute("data-duration"));
+ if (durationAttr != null && durationAttr > 0) return durationAttr;
+ const endAttr = parseNumeric(el.getAttribute("data-end"));
+ if (endAttr != null) {
+ const start = resolveStart(doc, el, startCache, visiting);
+ const delta = endAttr - start;
+ if (Number.isFinite(delta) && delta > 0) return delta;
+ }
+ return null;
+}
+
+export function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const compositionDivs = doc.querySelectorAll("[data-composition-id]");
const compositions: CompositionInfo[] = [];
+ const startCache = new Map();
+ const visiting = new Set();
compositionDivs.forEach((div) => {
const id = div.getAttribute("data-composition-id") ?? "unknown";
@@ -75,7 +142,7 @@ function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
timedChildren.forEach((el) => {
elementCount++;
- const start = parseFloat(el.getAttribute("data-start") ?? "0");
+ const start = resolveStart(doc, el, startCache, visiting);
const endAttr = el.getAttribute("data-end");
const durationAttr = el.getAttribute("data-duration");
@@ -141,9 +208,11 @@ export function parseSubComposition(
// Also check timed children for max end time
if (compDiv) {
const timedEls = compDiv.querySelectorAll("[data-start]");
+ const startCache = new Map();
+ const visiting = new Set();
timedEls.forEach((el) => {
elementCount = Math.max(elementCount, timedEls.length);
- const start = parseFloat(el.getAttribute("data-start") ?? "0");
+ const start = resolveStart(doc, el, startCache, visiting);
const endAttr = el.getAttribute("data-end");
const durAttr = el.getAttribute("data-duration");