mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user