mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(producer): give inlined media a document-unique render id (#3342)
* fix(producer): give inlined media a document-unique render id
Element ids are unique per composition file, but the render document is
the inlined union of every file. The producer merged the per-file media
lists and deduplicated by id, so clips that shared an id collapsed into a
single entry, and every id-keyed stage (extract, inject, visibility,
bounds) resolved to whichever element came first in the document. The
surviving clip's frames landed on the wrong element and the visible scene
rendered without footage.
Two shapes hit this, and neither is author error:
- Two scenes that each declare `<video id="clip">`. Legal per file, and
unavoidable when a scene is duplicated into a copy with inner ids
kept, or when one file is mounted twice.
- Two scenes that each declare a bare `<video>`. The timing compiler
numbers auto-ids per file, so both arrive as `hf-video-0` with no
authored id involved at all.
Stamp a document-unique `data-hf-render-id` while inlining, and read the
media list off the inlined document instead of merging per-file lists.
The render id equals the element id whenever that id is already unique,
so documents without a collision keep identical pipeline keys.
Author `id` attributes are left alone: 158 of the 161 registry blocks
reference their own ids from `#id` CSS or getElementById, so renaming
would trade broken footage for broken styling. The engine resolves media
elements through the render id instead, falling back to getElementById
for documents the producer never compiled.
Collecting from the inlined document also retires the per-file media
extraction in parseSubCompositions along with its offset bookkeeping;
host offsets are recovered from the composition hosts the clip sits in.
* fix(core): resolve render-frame siblings by render id in the runtime
The injector creates each `__render_frame_<id>__` sibling from the media
element's render id, but four runtime readers still built that id from the
plain `el.id`. On a document where two compositions share a media id, all
of them resolved the first collider's frame.
colorGrading is the one that changes pixels: findRenderFrameImage returns
the image the grading pass samples, with no class check to catch the
mismatch, so the second video was graded from the first one's frame.
media, mediaProxy and video-texture-compat use it as a render-mode or
substitute-source signal, where both colliders happen to agree during
render, but none of them should rest on that.
Add renderFrameSibling as the single owner of "which frame belongs to
this element" and route all four through it. It reads the stamped render
id and falls back to the author id, so a collision-free document resolves
exactly as before and an uncompiled one (preview, snapshot, check) is
unchanged.
The engine's in-page bridge keeps its own copy of the rule because code
serialized into page.evaluate cannot import; it now names core as the
definition, and a test pins the sibling-id format both sides build so
they cannot drift apart silently.
* refactor(engine): build render-frame sibling ids from core's definition
The drift guard named both sides but pinned one. renderFrameSibling.test
asserts core's format, while the engine rebuilt the same id from a literal
template at six independent sites. Changing the format on either side left
the test green and every runtime reader silently unable to find its frame —
this PR's own failure mode, one level up.
Export the affixes and renderFrameIdForRenderId from core, and take the id
from there at all six. Four sites resolve it on the Node side, where the
engine can import; the two that iterate the DOM in-page receive the affixes
as evaluate arguments, which avoids depending on bridge install order.
Also switch two `__hfMediaId?.(el) ?? el.id` reads to `||`. The bridge
returns "" for an element with neither id, so `??` kept the empty string
and built `__render_frame___`, which no reader looks for. Inert today
because the compiler assigns positional ids to id-less timed media, but it
made the two sides disagree in the one case they could.
This commit is contained in:
@@ -45,7 +45,10 @@ export function parseAudioElements(html: string): AudioElement[] {
|
||||
const tagName = (match[1] ?? "").toLowerCase() as "audio" | "video";
|
||||
const start = parseFloat(match[2] ?? "");
|
||||
|
||||
const idMatch = fullTag.match(/id=["']([^"']+)["']/);
|
||||
// `(?<![\w-])` keeps the plain-id pattern off `data-hf-render-id="…"` (and
|
||||
// `data-hf-id`), which would otherwise match first and report the wrong id.
|
||||
const idMatch = fullTag.match(/(?<![\w-])id=["']([^"']+)["']/);
|
||||
const renderIdMatch = fullTag.match(/data-hf-render-id=["']([^"']+)["']/);
|
||||
const srcMatch = fullTag.match(/src=["']([^"']+)["']/);
|
||||
if (!srcMatch) continue;
|
||||
|
||||
@@ -65,7 +68,9 @@ export function parseAudioElements(html: string): AudioElement[] {
|
||||
: 0;
|
||||
|
||||
elements.push({
|
||||
id: idMatch?.[1] || `media-${elements.length}`,
|
||||
// The stamped render id is document-unique; the authored id is only
|
||||
// unique within one composition file. See core's mediaRenderIds.ts.
|
||||
id: renderIdMatch?.[1] || idMatch?.[1] || `media-${elements.length}`,
|
||||
src: srcMatch[1] ?? "",
|
||||
start: isNaN(start) ? 0 : start,
|
||||
duration,
|
||||
|
||||
@@ -2561,3 +2561,135 @@ describe("compileForRender non-media payload sniff (STUDIO-5433)", () => {
|
||||
expect(warnings.join("\n")).toContain("a1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate media ids across nested compositions", () => {
|
||||
// Element ids are unique per composition FILE; the render document is the
|
||||
// inlined union of every file. The producer used to merge the per-file media
|
||||
// lists and deduplicate by id, so colliding clips collapsed into one entry
|
||||
// and the survivor's frames were injected onto whichever element came first
|
||||
// in the document — leaving the visible scene without footage (#3340).
|
||||
function sceneWithVideoId(label: string, mediaStart: number): string {
|
||||
return `<div data-composition-id="${label}" data-start="0" data-duration="3"
|
||||
data-width="640" data-height="360">
|
||||
<video id="clip" src="../assets/long-take.mp4" data-start="0" data-duration="3"
|
||||
data-media-start="${mediaStart}" data-track-index="0"></video>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function writeTwoSceneProject(
|
||||
sceneAFile: string,
|
||||
sceneBFile: string = sceneAFile,
|
||||
sceneBody: (label: string, mediaStart: number) => string = sceneWithVideoId,
|
||||
): { projectDir: string; indexPath: string } {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-dup-media-"));
|
||||
const compositionsDir = join(projectDir, "compositions");
|
||||
mkdirSync(compositionsDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="root" data-start="0" data-duration="6"
|
||||
data-width="640" data-height="360">
|
||||
<div id="scene-a-host" data-composition-id="scene-a" data-composition-src="compositions/${sceneAFile}"
|
||||
data-start="0" data-duration="3" data-width="640" data-height="360"></div>
|
||||
<div id="scene-b-host" data-composition-id="scene-b" data-composition-src="compositions/${sceneBFile}"
|
||||
data-start="3" data-duration="3" data-width="640" data-height="360"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const wrap = (label: string, mediaStart: number) =>
|
||||
`<template id="${label}-template">\n${sceneBody(label, mediaStart)}\n</template>`;
|
||||
writeFileSync(join(compositionsDir, sceneAFile), wrap("scene-a", 5));
|
||||
if (sceneBFile !== sceneAFile) {
|
||||
writeFileSync(join(compositionsDir, sceneBFile), wrap("scene-b", 50));
|
||||
}
|
||||
|
||||
return { projectDir, indexPath: join(projectDir, "index.html") };
|
||||
}
|
||||
|
||||
it("keeps both clips when two scenes declare the same video id", async () => {
|
||||
const { projectDir, indexPath } = writeTwoSceneProject("scene-a.html", "scene-b.html");
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
expect(compiled.videos).toHaveLength(2);
|
||||
expect(compiled.videos[0]).toMatchObject({ start: 0, end: 3, mediaStart: 5 });
|
||||
expect(compiled.videos[1]).toMatchObject({ start: 3, end: 6, mediaStart: 50 });
|
||||
});
|
||||
|
||||
it("gives the colliding clips ids that each address one element", async () => {
|
||||
const { projectDir, indexPath } = writeTwoSceneProject("scene-a.html", "scene-b.html");
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
const ids = compiled.videos.map((v) => v.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
|
||||
// One element per id is exactly what the frame injector relies on.
|
||||
const { document } = parseHTML(compiled.html);
|
||||
for (const id of ids) {
|
||||
expect(document.querySelectorAll(`[data-hf-render-id="${id}"]`)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps author ids intact so scene CSS and scripts still resolve", async () => {
|
||||
const { projectDir, indexPath } = writeTwoSceneProject("scene-a.html", "scene-b.html");
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
const { document } = parseHTML(compiled.html);
|
||||
expect(document.querySelectorAll('video[id="clip"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps both clips when the same scene file is mounted twice", async () => {
|
||||
// The author cannot make these unique: it is one file, mounted twice.
|
||||
const { projectDir, indexPath } = writeTwoSceneProject("scene.html");
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
expect(compiled.videos).toHaveLength(2);
|
||||
expect(compiled.videos[0]).toMatchObject({ start: 0, end: 3 });
|
||||
expect(compiled.videos[1]).toMatchObject({ start: 3, end: 6 });
|
||||
});
|
||||
|
||||
it("keeps both clips when neither scene names its video", async () => {
|
||||
// No authored id at all: the timing compiler numbers auto-ids per file, so
|
||||
// both scenes arrive as `hf-video-0`.
|
||||
const { projectDir, indexPath } = writeTwoSceneProject(
|
||||
"scene-a.html",
|
||||
"scene-b.html",
|
||||
(label, mediaStart) =>
|
||||
`<div data-composition-id="${label}" data-start="0" data-duration="3"
|
||||
data-width="640" data-height="360">
|
||||
<video src="../assets/long-take.mp4" data-start="0" data-duration="3"
|
||||
data-media-start="${mediaStart}" data-track-index="0"></video>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
expect(compiled.videos).toHaveLength(2);
|
||||
expect(compiled.videos.map((v) => v.mediaStart)).toEqual([5, 50]);
|
||||
});
|
||||
|
||||
it("keeps both audio tracks when two scenes declare the same audio id", async () => {
|
||||
const { projectDir, indexPath } = writeTwoSceneProject(
|
||||
"scene-a.html",
|
||||
"scene-b.html",
|
||||
(label, mediaStart) =>
|
||||
`<div data-composition-id="${label}" data-start="0" data-duration="3"
|
||||
data-width="640" data-height="360">
|
||||
<audio id="vo" src="../assets/narration.wav" data-start="0" data-duration="3"
|
||||
data-media-start="${mediaStart}" data-track-index="1"></audio>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
expect(compiled.audios).toHaveLength(2);
|
||||
expect(compiled.audios[0]).toMatchObject({ start: 0, end: 3, mediaStart: 5 });
|
||||
expect(compiled.audios[1]).toMatchObject({ start: 3, end: 6, mediaStart: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
||||
import {
|
||||
assignBundledRuntimeCompositionIds,
|
||||
assignMediaRenderIds,
|
||||
type BundledHostCompositionIdentity,
|
||||
buildVariablesByCompScript,
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
@@ -45,12 +46,10 @@ import {
|
||||
import { isUnresolvedAssetPlaceholder } from "@hyperframes/parsers/asset-resolution";
|
||||
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import { collectRenderMedia } from "./renderMediaCollector.js";
|
||||
import {
|
||||
parseVideoElements,
|
||||
parseImageElements,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
parseAudioElements,
|
||||
type AudioElement,
|
||||
type AudioVolumeKeyframe,
|
||||
type MediaProbeProfile,
|
||||
@@ -252,14 +251,6 @@ export interface RenderModeHints {
|
||||
reasons: RenderModeHint[];
|
||||
}
|
||||
|
||||
function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
|
||||
const deduped = new Map<string, T>();
|
||||
for (const element of elements) {
|
||||
deduped.set(element.id, element);
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
|
||||
const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
|
||||
@@ -618,26 +609,21 @@ async function compileHtmlFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sub-compositions referenced via data-composition-src.
|
||||
* Reads each file, compiles it, extracts video/audio, adjusts timing offsets.
|
||||
* Recurses into nested sub-compositions with accumulated offsets.
|
||||
* Compile every sub-composition referenced via data-composition-src, keyed by
|
||||
* its source path for the inliner to hoist into the render document.
|
||||
* Recurses so nested references are compiled too.
|
||||
*
|
||||
* Media used to be extracted here as well, with each file's clips offset onto
|
||||
* the parent timeline. That is now read off the inlined document instead
|
||||
* (collectRenderMedia): per-file extraction had to merge on element id, which
|
||||
* is not unique across files, so colliding clips silently collapsed (#3340).
|
||||
*/
|
||||
async function parseSubCompositions(
|
||||
html: string,
|
||||
projectDir: string,
|
||||
downloadDir: string,
|
||||
parentOffset: number = 0,
|
||||
parentEnd: number = Infinity,
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<{
|
||||
videos: VideoElement[];
|
||||
audios: AudioElement[];
|
||||
images: ImageElement[];
|
||||
subCompositions: Map<string, string>;
|
||||
}> {
|
||||
const videos: VideoElement[] = [];
|
||||
const audios: AudioElement[] = [];
|
||||
const images: ImageElement[] = [];
|
||||
): Promise<{ subCompositions: Map<string, string> }> {
|
||||
const subCompositions = new Map<string, string>();
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
@@ -646,8 +632,6 @@ async function parseSubCompositions(
|
||||
// Build work items, filtering out invalid/circular entries synchronously
|
||||
const workItems: Array<{
|
||||
srcPath: string;
|
||||
absoluteStart: number;
|
||||
absoluteEnd: number;
|
||||
filePath: string;
|
||||
rawSubHtml: string;
|
||||
nestedVisited: Set<string>;
|
||||
@@ -657,12 +641,6 @@ async function parseSubCompositions(
|
||||
const srcPath = el.getAttribute("data-composition-src");
|
||||
if (!srcPath) continue;
|
||||
|
||||
const elStart = parseFloat(el.getAttribute("data-start") || "0");
|
||||
const elEnd = parseStrictFiniteTimingNumber(el.getAttribute("data-end")) ?? Infinity;
|
||||
|
||||
const absoluteStart = parentOffset + elStart;
|
||||
const absoluteEnd = Math.min(parentEnd, isFinite(elEnd) ? parentOffset + elEnd : Infinity);
|
||||
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
|
||||
// Circular reference guard
|
||||
@@ -678,7 +656,7 @@ async function parseSubCompositions(
|
||||
const nestedVisited = new Set(visited);
|
||||
nestedVisited.add(filePath);
|
||||
|
||||
workItems.push({ srcPath, absoluteStart, absoluteEnd, filePath, rawSubHtml, nestedVisited });
|
||||
workItems.push({ srcPath, filePath, rawSubHtml, nestedVisited });
|
||||
}
|
||||
|
||||
// Parallelize file compilation + recursive parsing
|
||||
@@ -694,24 +672,13 @@ async function parseSubCompositions(
|
||||
compiledSub,
|
||||
projectDir,
|
||||
downloadDir,
|
||||
item.absoluteStart,
|
||||
item.absoluteEnd,
|
||||
item.nestedVisited,
|
||||
);
|
||||
|
||||
const subVideos = parseVideoElements(compiledSub);
|
||||
const subAudios = parseAudioElements(compiledSub);
|
||||
const subImages = parseImageElements(compiledSub);
|
||||
|
||||
return {
|
||||
srcPath: item.srcPath,
|
||||
compiledSub,
|
||||
nested,
|
||||
subVideos,
|
||||
subAudios,
|
||||
subImages,
|
||||
absoluteStart: item.absoluteStart,
|
||||
absoluteEnd: item.absoluteEnd,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -723,55 +690,9 @@ async function parseSubCompositions(
|
||||
for (const [key, value] of r.nested.subCompositions) {
|
||||
subCompositions.set(key, value);
|
||||
}
|
||||
videos.push(...r.nested.videos);
|
||||
audios.push(...r.nested.audios);
|
||||
images.push(...r.nested.images);
|
||||
|
||||
for (const v of r.subVideos) {
|
||||
v.start += r.absoluteStart;
|
||||
v.end += r.absoluteStart;
|
||||
if (v.end > r.absoluteEnd) {
|
||||
v.end = r.absoluteEnd;
|
||||
}
|
||||
if (v.start < r.absoluteEnd) {
|
||||
videos.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of r.subAudios) {
|
||||
a.start += r.absoluteStart;
|
||||
a.end += r.absoluteStart;
|
||||
if (a.end > r.absoluteEnd) {
|
||||
a.end = r.absoluteEnd;
|
||||
}
|
||||
if (a.start < r.absoluteEnd) {
|
||||
audios.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
for (const img of r.subImages) {
|
||||
img.start += r.absoluteStart;
|
||||
img.end += r.absoluteStart;
|
||||
if (img.end > r.absoluteEnd) {
|
||||
img.end = r.absoluteEnd;
|
||||
}
|
||||
if (img.start < r.absoluteEnd) {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
r.subVideos.length > 0 ||
|
||||
r.subAudios.length > 0 ||
|
||||
r.subImages.length > 0 ||
|
||||
r.nested.videos.length > 0 ||
|
||||
r.nested.audios.length > 0 ||
|
||||
r.nested.images.length > 0
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
return { videos, audios, images, subCompositions };
|
||||
return { subCompositions };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1130,6 +1051,12 @@ function inlineSubCompositions(
|
||||
variableOverrides,
|
||||
);
|
||||
|
||||
// Inlining is what makes element ids ambiguous: each composition file is
|
||||
// internally consistent, the union of them is not. Hand every media element a
|
||||
// document-unique key here, while the merged document is in hand and before
|
||||
// anything downstream keys media on an id. See core's mediaRenderIds.ts.
|
||||
assignMediaRenderIds(document as unknown as Document);
|
||||
|
||||
return document.toString();
|
||||
}
|
||||
|
||||
@@ -1882,13 +1809,8 @@ export async function compileForRender(
|
||||
options.log,
|
||||
);
|
||||
|
||||
// Parse sub-compositions first (extracts media + compiled HTML for each)
|
||||
const {
|
||||
videos: subVideos,
|
||||
audios: subAudios,
|
||||
images: subImages,
|
||||
subCompositions,
|
||||
} = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
|
||||
// Compile each referenced sub-composition so the inliner can hoist it.
|
||||
const { subCompositions } = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
|
||||
|
||||
// Ensure the HTML is a full document before inlining sub-compositions.
|
||||
// When index.html is a fragment (no <html>/<head>/<body>), linkedom.parseHTML()
|
||||
@@ -2030,17 +1952,12 @@ export async function compileForRender(
|
||||
externalAssets.set(relPath, absPath);
|
||||
}
|
||||
|
||||
// Parse main HTML elements
|
||||
const mainVideos = parseVideoElements(html);
|
||||
const mainAudios = parseAudioElements(html);
|
||||
const mainImages = parseImageElements(html);
|
||||
|
||||
// Keep inlined sub-composition media authoritative on ID collisions.
|
||||
// inlineSubCompositions() hoists those nodes into the final HTML, so the
|
||||
// producer should follow the same precedence the runtime sees in the merged DOM.
|
||||
const videos = dedupeElementsById([...mainVideos, ...subVideos]);
|
||||
const audios = dedupeElementsById([...mainAudios, ...subAudios]);
|
||||
const images = dedupeElementsById([...mainImages, ...subImages]);
|
||||
// Read the media list off the inlined document rather than merging the
|
||||
// per-file lists. Merging deduplicated by element id, which is only unique
|
||||
// within one composition file: two scenes declaring `<video id="clip">` — or
|
||||
// two bare `<video>`s, both auto-numbered `hf-video-0` — collapsed into one
|
||||
// entry and injected frames onto whichever element came first. See #3340.
|
||||
const { videos, audios, images } = collectRenderMedia(html);
|
||||
|
||||
// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
|
||||
// ffprobe subprocesses and should not block compilation since they only produce warnings.
|
||||
@@ -2570,23 +2487,13 @@ export async function recompileWithResolutions(
|
||||
|
||||
const html = injectDurations(compiled.html, resolutions);
|
||||
|
||||
// Re-parse sub-compositions with the updated parent bounds
|
||||
const {
|
||||
videos: subVideos,
|
||||
audios: subAudios,
|
||||
images: subImages,
|
||||
subCompositions,
|
||||
} = await parseSubCompositions(html, projectDir, downloadDir);
|
||||
|
||||
const mainVideos = parseVideoElements(html);
|
||||
const mainAudios = parseAudioElements(html);
|
||||
const mainImages = parseImageElements(html);
|
||||
|
||||
// Keep inlined sub-composition media authoritative on ID collisions.
|
||||
const hasSubMedia = subVideos.length > 0 || subAudios.length > 0 || subImages.length > 0;
|
||||
const videos = hasSubMedia ? dedupeElementsById([...mainVideos, ...subVideos]) : compiled.videos;
|
||||
const audios = hasSubMedia ? dedupeElementsById([...mainAudios, ...subAudios]) : compiled.audios;
|
||||
const images = hasSubMedia ? dedupeElementsById([...mainImages, ...subImages]) : compiled.images;
|
||||
// Re-resolve the sub-composition map against the updated HTML, but keep the
|
||||
// media list from the first pass. Resolving a composition's duration stamps a
|
||||
// `data-end` on its host, and re-collecting would newly clamp clips to it —
|
||||
// a retiming, not an identity fix. `compiled.videos` was already collected
|
||||
// from this same inlined document, so it is complete and correctly keyed.
|
||||
const { subCompositions } = await parseSubCompositions(html, projectDir, downloadDir);
|
||||
const { videos, audios, images } = compiled;
|
||||
|
||||
const remaining = compiled.unresolvedCompositions.filter(
|
||||
(c) => !resolutions.some((r) => r.id === c.id),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Collect the render pipeline's media list from the fully inlined document.
|
||||
*
|
||||
* Sub-composition media used to be gathered from each composition FILE before
|
||||
* inlining, then merged with the main document's media and deduplicated by
|
||||
* element id. That merge is unsound: ids are unique per file, not per render
|
||||
* document, so two scenes that both declare `<video id="clip">` — or that both
|
||||
* declare a bare `<video>` and get the per-file auto-id `hf-video-0` — collapse
|
||||
* into a single entry. See mediaRenderIds.ts for the full failure.
|
||||
*
|
||||
* Reading the inlined document instead makes the render document the single
|
||||
* source of truth for what media exists: every element is present exactly once,
|
||||
* `assignMediaRenderIds` has already given it a document-unique key, and the
|
||||
* timeline offsets are recoverable from the composition hosts it sits inside.
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core";
|
||||
import {
|
||||
parseVideoElements,
|
||||
parseImageElements,
|
||||
parseAudioElements,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
type AudioElement,
|
||||
} from "@hyperframes/engine";
|
||||
|
||||
/**
|
||||
* Marks a host element that `inlineSubCompositions` hoisted a composition into.
|
||||
* Set unconditionally on every inlined host, which makes it the reliable signal
|
||||
* for "this ancestor shifts its children along the timeline".
|
||||
*/
|
||||
const COMPOSITION_HOST_ATTR = "data-composition-file";
|
||||
|
||||
interface HostWindow {
|
||||
/** Seconds to add to a descendant's authored, scene-relative start. */
|
||||
offset: number;
|
||||
/** Absolute time past which a descendant is outside its host, or Infinity. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity };
|
||||
|
||||
function parseNumeric(value: string | null): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a media element's chain of composition hosts into one window.
|
||||
*
|
||||
* Mirrors the offset arithmetic `parseSubCompositions` applied while walking
|
||||
* the composition file tree, so a document that has no id collisions produces
|
||||
* exactly the timings it did before. Only `data-end` bounds a host: a host
|
||||
* carrying just `data-duration` was unbounded there too, and widening that here
|
||||
* would silently retime existing compositions rather than fix identity.
|
||||
*/
|
||||
function resolveHostWindow(element: Element): HostWindow {
|
||||
const hosts: Element[] = [];
|
||||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||||
if (ancestor.hasAttribute(COMPOSITION_HOST_ATTR)) hosts.push(ancestor);
|
||||
}
|
||||
if (hosts.length === 0) return ROOT_WINDOW;
|
||||
|
||||
let offset = 0;
|
||||
let limit = Infinity;
|
||||
// parentElement walks leaf → root; the offsets accumulate root → leaf.
|
||||
for (const host of hosts.reverse()) {
|
||||
const hostStart = parseNumeric(host.getAttribute("data-start")) ?? 0;
|
||||
const hostEnd = parseNumeric(host.getAttribute("data-end"));
|
||||
if (hostEnd != null) limit = Math.min(limit, offset + hostEnd);
|
||||
offset += hostStart;
|
||||
}
|
||||
return { offset, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each render id to the window of the composition hosts it is nested in.
|
||||
* Keyed on the render id rather than document position so the caller never has
|
||||
* to assume two separate parses walk the document in the same order.
|
||||
*/
|
||||
function collectHostWindows(html: string): Map<string, HostWindow> {
|
||||
const { document } = parseHTML(html);
|
||||
const windows = new Map<string, HostWindow>();
|
||||
for (const element of document.querySelectorAll(`[${MEDIA_RENDER_ID_ATTR}]`)) {
|
||||
const renderId = element.getAttribute(MEDIA_RENDER_ID_ATTR);
|
||||
if (!renderId) continue;
|
||||
windows.set(renderId, resolveHostWindow(element as unknown as Element));
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift a scene-relative window onto the root timeline.
|
||||
* Returns null when the clip starts after its host has already ended, matching
|
||||
* the `start < absoluteEnd` drop the file-tree walk applied.
|
||||
*/
|
||||
function toAbsoluteWindow(
|
||||
start: number,
|
||||
end: number,
|
||||
window: HostWindow,
|
||||
): { start: number; end: number } | null {
|
||||
const absoluteStart = start + window.offset;
|
||||
if (absoluteStart >= window.limit) return null;
|
||||
const absoluteEnd = end + window.offset;
|
||||
return { start: absoluteStart, end: Math.min(absoluteEnd, window.limit) };
|
||||
}
|
||||
|
||||
export interface RenderMedia {
|
||||
videos: VideoElement[];
|
||||
audios: AudioElement[];
|
||||
images: ImageElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every media element in the inlined render document, with each clip's
|
||||
* window resolved onto the root timeline.
|
||||
*
|
||||
* Expects `assignMediaRenderIds` to have run: the parsers report the stamped
|
||||
* render id as each element's `id`, which is what the rest of the pipeline
|
||||
* keys on and what the engine resolves back to a DOM node.
|
||||
*/
|
||||
export function collectRenderMedia(html: string): RenderMedia {
|
||||
const windows = collectHostWindows(html);
|
||||
const windowFor = (id: string): HostWindow => windows.get(id) ?? ROOT_WINDOW;
|
||||
|
||||
const videos: VideoElement[] = [];
|
||||
for (const video of parseVideoElements(html)) {
|
||||
const absolute = toAbsoluteWindow(video.start, video.end, windowFor(video.id));
|
||||
if (absolute) videos.push({ ...video, ...absolute });
|
||||
}
|
||||
|
||||
const images: ImageElement[] = [];
|
||||
for (const image of parseImageElements(html)) {
|
||||
const absolute = toAbsoluteWindow(image.start, image.end, windowFor(image.id));
|
||||
if (absolute) images.push({ ...image, ...absolute });
|
||||
}
|
||||
|
||||
// A <video data-has-audio> track is reported as "<renderId>-audio"; strip the
|
||||
// suffix to look the element's host window back up.
|
||||
const audios: AudioElement[] = [];
|
||||
for (const audio of parseAudioElements(html)) {
|
||||
const elementId = audio.type === "video" ? audio.id.replace(/-audio$/, "") : audio.id;
|
||||
// The mixer reads end === 0 as "run to the natural media length", so an
|
||||
// unbounded track must stay unbounded rather than collapse onto its start.
|
||||
const authoredEnd = audio.end > 0 ? audio.end : Infinity;
|
||||
const absolute = toAbsoluteWindow(audio.start, authoredEnd, windowFor(elementId));
|
||||
if (!absolute) continue;
|
||||
audios.push({
|
||||
...audio,
|
||||
start: absolute.start,
|
||||
end: Number.isFinite(absolute.end) ? absolute.end : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { videos, audios, images };
|
||||
}
|
||||
Reference in New Issue
Block a user