fix(producer): render -c <scene> uses the scene's own duration, not the project's (#2087)

* fix(producer): render -c <scene> uses the scene's own duration, not the project's

When rendering a single sub-composition standalone (`hyperframes render -c
compositions/scene.html`), the producer extracts the scene's mount from
index.html and wraps it in a shallow clone of the master root. That clone
kept the master's `data-duration`, so the standalone composition advertised
the whole project's length instead of the scene's own: a 2s scene rendered
for the full 12s project, and a master that derives its length from sibling
mounts (now removed) produced "Composition has zero duration".

Re-point the extracted wrapper's `data-duration` at the scene's own, read
from the scene file's `<template>` root (the source of truth for that scene),
with a fallback to the mount's `data-duration`. Full-project renders are
unaffected — they never take the extraction branch.

Verified end-to-end via the pre-capture duration gate: a 2s scene now resolves
to 2s and a 10s scene to 10s (both were 12s), while the full index render stays
at 12s. Adds unit coverage for both the scene-file and mount-fallback paths.

* test(producer): distinguish scene and mount durations
This commit is contained in:
Miguel Ángel
2026-07-11 18:32:12 -04:00
committed by GitHub
parent 9c98c1e82a
commit 6b7431bf45
2 changed files with 79 additions and 2 deletions
@@ -113,6 +113,44 @@ describe("extractStandaloneEntryFromIndex", () => {
expect(extracted).toBeNull();
});
it("re-points the wrapper duration at the scene's own, not the master's", () => {
const indexHtml = `<!DOCTYPE html>
<html>
<body>
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
</div>
</body>
</html>`;
const sceneHtml = `<template id="scene1-template"><div data-composition-id="scene1" data-width="640" data-height="360" data-duration="3"></div></template>`;
const extracted = extractStandaloneEntryFromIndex(
indexHtml,
"compositions/scene1.html",
sceneHtml,
);
// The extracted standalone advertises the scene file's 3s, not the mount's 2s or master's 12s.
expect(extracted).toContain('data-duration="3"');
expect(extracted).not.toContain('data-duration="12"');
});
it("falls back to the mount's data-duration when the scene file isn't supplied", () => {
const indexHtml = `<!DOCTYPE html>
<html>
<body>
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
</div>
</body>
</html>`;
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/scene1.html");
expect(extracted).toContain('data-duration="2"');
expect(extracted).not.toContain('data-duration="12"');
});
});
describe("captureAttemptMadeProgress", () => {
@@ -965,7 +965,27 @@ function normalizeCompositionSrcPath(srcPath: string): string {
return srcPath.replace(/\\/g, "/").replace(/^\.\//, "");
}
function createStandaloneEntryRenderClone(root: Element, host: Element): Element {
/**
* Read the `data-duration` off a scene file's `<template>` root — the scene's
* own authored length. linkedom does not implement inert `<template>` content,
* so we re-parse `template.innerHTML` (the pattern htmlBundler uses) to reach
* the composition root inside it. Returns null when the file has no template
* or the root declares no duration.
*/
function readSceneRootDuration(entryHtml: string | undefined): string | null {
if (!entryHtml) return null;
const { document } = parseHTML(entryHtml);
const template = document.querySelector("template");
const scope = template ? parseHTML(template.innerHTML).document : document;
const root = scope.querySelector("[data-composition-id]") as Element | null;
return root?.getAttribute("data-duration") ?? null;
}
function createStandaloneEntryRenderClone(
root: Element,
host: Element,
sceneDuration: string | null,
): Element {
// linkedom's cloneNode returns `any` (not `Node`), so the Element cast
// is needed to access setAttribute/appendChild without losing type safety.
const hostClone = host.cloneNode(true) as Element;
@@ -974,6 +994,18 @@ function createStandaloneEntryRenderClone(root: Element, host: Element): Element
if (root === host) return hostClone;
const rootClone = root.cloneNode(false) as Element;
// The standalone composition IS the mounted scene, not the master shell that
// wraps it. A shallow clone of the master root otherwise keeps the master's
// data-duration (the whole project's length), so `render -c <scene>` rendered
// the scene for the entire project duration — or threw "Composition has zero
// duration" when the master derived its length from siblings now removed.
// Re-point the wrapper's duration at the scene's own; drop it (derive from the
// single child) only when the scene declared none.
if (sceneDuration != null) {
rootClone.setAttribute("data-duration", sceneDuration);
} else {
rootClone.removeAttribute("data-duration");
}
rootClone.appendChild(hostClone);
return rootClone;
}
@@ -1288,6 +1320,7 @@ export function shouldDiscardProbeSessionForPageSideCompositing(args: {
export function extractStandaloneEntryFromIndex(
indexHtml: string,
entryFile: string,
entryHtml?: string,
): string | null {
const normalizedEntryFile = normalizeCompositionSrcPath(entryFile);
const { document } = parseHTML(indexHtml);
@@ -1313,7 +1346,12 @@ export function extractStandaloneEntryFromIndex(
) ?? null;
if (!root) return null;
const renderClone = createStandaloneEntryRenderClone(root, host);
// The scene file is the source of truth for its own duration; fall back to the
// mount's data-duration (its window in the master timeline) when the scene
// file content isn't supplied.
const sceneDuration = readSceneRootDuration(entryHtml) ?? host.getAttribute("data-duration");
const renderClone = createStandaloneEntryRenderClone(root, host, sceneDuration);
replaceBodyWithRenderClone(body, renderClone);
return document.toString();
@@ -1490,6 +1528,7 @@ export async function executeRenderJob(
const standaloneHtml = extractStandaloneEntryFromIndex(
readFileSync(projectIndexPath, "utf-8"),
entryFile,
rawEntry,
);
if (!standaloneHtml) {
throw new Error(