Skip to content

Commit 6b7431b

Browse files
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
1 parent 9c98c1e commit 6b7431b

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

packages/producer/src/services/renderOrchestrator.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ describe("extractStandaloneEntryFromIndex", () => {
113113

114114
expect(extracted).toBeNull();
115115
});
116+
117+
it("re-points the wrapper duration at the scene's own, not the master's", () => {
118+
const indexHtml = `<!DOCTYPE html>
119+
<html>
120+
<body>
121+
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
122+
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
123+
</div>
124+
</body>
125+
</html>`;
126+
const sceneHtml = `<template id="scene1-template"><div data-composition-id="scene1" data-width="640" data-height="360" data-duration="3"></div></template>`;
127+
128+
const extracted = extractStandaloneEntryFromIndex(
129+
indexHtml,
130+
"compositions/scene1.html",
131+
sceneHtml,
132+
);
133+
134+
// The extracted standalone advertises the scene file's 3s, not the mount's 2s or master's 12s.
135+
expect(extracted).toContain('data-duration="3"');
136+
expect(extracted).not.toContain('data-duration="12"');
137+
});
138+
139+
it("falls back to the mount's data-duration when the scene file isn't supplied", () => {
140+
const indexHtml = `<!DOCTYPE html>
141+
<html>
142+
<body>
143+
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
144+
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
145+
</div>
146+
</body>
147+
</html>`;
148+
149+
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/scene1.html");
150+
151+
expect(extracted).toContain('data-duration="2"');
152+
expect(extracted).not.toContain('data-duration="12"');
153+
});
116154
});
117155

118156
describe("captureAttemptMadeProgress", () => {

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,27 @@ function normalizeCompositionSrcPath(srcPath: string): string {
965965
return srcPath.replace(/\\/g, "/").replace(/^\.\//, "");
966966
}
967967

968-
function createStandaloneEntryRenderClone(root: Element, host: Element): Element {
968+
/**
969+
* Read the `data-duration` off a scene file's `<template>` root — the scene's
970+
* own authored length. linkedom does not implement inert `<template>` content,
971+
* so we re-parse `template.innerHTML` (the pattern htmlBundler uses) to reach
972+
* the composition root inside it. Returns null when the file has no template
973+
* or the root declares no duration.
974+
*/
975+
function readSceneRootDuration(entryHtml: string | undefined): string | null {
976+
if (!entryHtml) return null;
977+
const { document } = parseHTML(entryHtml);
978+
const template = document.querySelector("template");
979+
const scope = template ? parseHTML(template.innerHTML).document : document;
980+
const root = scope.querySelector("[data-composition-id]") as Element | null;
981+
return root?.getAttribute("data-duration") ?? null;
982+
}
983+
984+
function createStandaloneEntryRenderClone(
985+
root: Element,
986+
host: Element,
987+
sceneDuration: string | null,
988+
): Element {
969989
// linkedom's cloneNode returns `any` (not `Node`), so the Element cast
970990
// is needed to access setAttribute/appendChild without losing type safety.
971991
const hostClone = host.cloneNode(true) as Element;
@@ -974,6 +994,18 @@ function createStandaloneEntryRenderClone(root: Element, host: Element): Element
974994
if (root === host) return hostClone;
975995

976996
const rootClone = root.cloneNode(false) as Element;
997+
// The standalone composition IS the mounted scene, not the master shell that
998+
// wraps it. A shallow clone of the master root otherwise keeps the master's
999+
// data-duration (the whole project's length), so `render -c <scene>` rendered
1000+
// the scene for the entire project duration — or threw "Composition has zero
1001+
// duration" when the master derived its length from siblings now removed.
1002+
// Re-point the wrapper's duration at the scene's own; drop it (derive from the
1003+
// single child) only when the scene declared none.
1004+
if (sceneDuration != null) {
1005+
rootClone.setAttribute("data-duration", sceneDuration);
1006+
} else {
1007+
rootClone.removeAttribute("data-duration");
1008+
}
9771009
rootClone.appendChild(hostClone);
9781010
return rootClone;
9791011
}
@@ -1288,6 +1320,7 @@ export function shouldDiscardProbeSessionForPageSideCompositing(args: {
12881320
export function extractStandaloneEntryFromIndex(
12891321
indexHtml: string,
12901322
entryFile: string,
1323+
entryHtml?: string,
12911324
): string | null {
12921325
const normalizedEntryFile = normalizeCompositionSrcPath(entryFile);
12931326
const { document } = parseHTML(indexHtml);
@@ -1313,7 +1346,12 @@ export function extractStandaloneEntryFromIndex(
13131346
) ?? null;
13141347
if (!root) return null;
13151348

1316-
const renderClone = createStandaloneEntryRenderClone(root, host);
1349+
// The scene file is the source of truth for its own duration; fall back to the
1350+
// mount's data-duration (its window in the master timeline) when the scene
1351+
// file content isn't supplied.
1352+
const sceneDuration = readSceneRootDuration(entryHtml) ?? host.getAttribute("data-duration");
1353+
1354+
const renderClone = createStandaloneEntryRenderClone(root, host, sceneDuration);
13171355
replaceBodyWithRenderClone(body, renderClone);
13181356

13191357
return document.toString();
@@ -1490,6 +1528,7 @@ export async function executeRenderJob(
14901528
const standaloneHtml = extractStandaloneEntryFromIndex(
14911529
readFileSync(projectIndexPath, "utf-8"),
14921530
entryFile,
1531+
rawEntry,
14931532
);
14941533
if (!standaloneHtml) {
14951534
throw new Error(

0 commit comments

Comments
 (0)