Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/engine/src/services/audioMixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { extractAudioMetadata } from "../utils/ffprobe.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";

export type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
Expand All @@ -24,7 +25,7 @@ interface ExtractResult {

export function parseAudioElements(html: string): AudioElement[] {
const elements: AudioElement[] = [];
const { document } = parseHTML(html);
const { document } = parseHTML(unwrapTemplate(html));

// Parse <audio> elements
const audioEls = document.querySelectorAll("audio[id][src]");
Expand Down
6 changes: 4 additions & 2 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import {
FRAME_FILENAME_PREFIX,
Expand Down Expand Up @@ -102,7 +103,7 @@ export interface ExtractionResult {

export function parseVideoElements(html: string): VideoElement[] {
const videos: VideoElement[] = [];
const { document } = parseHTML(html);
const { document } = parseHTML(unwrapTemplate(html));

const videoEls = document.querySelectorAll("video[src]");
let autoIdCounter = 0;
Expand Down Expand Up @@ -156,7 +157,8 @@ export interface ImageElement {

export function parseImageElements(html: string): ImageElement[] {
const images: ImageElement[] = [];
const { document } = parseHTML(html);
// See parseVideoElements above for why we unwrap <template>.
const { document } = parseHTML(unwrapTemplate(html));

const imgEls = document.querySelectorAll("img[src]");
let autoIdCounter = 0;
Expand Down
63 changes: 63 additions & 0 deletions packages/engine/src/utils/htmlTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { unwrapTemplate } from "./htmlTemplate.js";

describe("unwrapTemplate", () => {
it("returns input unchanged when there is no <template>", () => {
const html = `<div>hello</div>`;
expect(unwrapTemplate(html)).toBe(html);
});

it("unwraps the contents of a top-level <template>", () => {
const inner = `<div id="root"><audio id="a" src="a.mp3"></audio></div>`;
const html = `<!doctype html><html><body><template>${inner}</template></body></html>`;
expect(unwrapTemplate(html)).toBe(inner);
});

it("handles attributes on the <template> tag", () => {
const inner = `<span>hi</span>`;
const html = `<template id="t" data-x="1">${inner}</template>`;
expect(unwrapTemplate(html)).toBe(inner);
});

it("returns input unchanged when <template> has no closing tag", () => {
const html = `<template><div>broken`;
expect(unwrapTemplate(html)).toBe(html);
});

it("returns empty string for an empty template", () => {
const html = `<body><template></template></body>`;
expect(unwrapTemplate(html)).toBe("");
});

// Nested templates: the greedy match intentionally captures everything
// from the first <template> to the last </template>. A sub-composition
// that embeds its own <template> (e.g. for cloning) keeps that inner
// template intact after one unwrap — we only peel the outermost wrapper.
it("only unwraps the outermost <template> when nested", () => {
const inner = `outer-before<template>inner-content</template>outer-after`;
const html = `<body><template>${inner}</template></body>`;
expect(unwrapTemplate(html)).toBe(inner);
});

// Invariant: after one unwrap, any remaining <template> in the output is
// user content (e.g. a cloning template inside the composition DOM), not
// a second sub-composition wrapper. Running unwrapTemplate a second time
// would incorrectly strip user content, so callers should unwrap once.
it("is intended to be called once per sub-composition level", () => {
const userTemplate = `<template id="row"><tr><td></td></tr></template>`;
const composition = `<div>${userTemplate}</div>`;
const wrapped = `<template>${composition}</template>`;
expect(unwrapTemplate(wrapped)).toBe(composition);
});

// Known limitation: the greedy regex anchors on the LAST </template>, so
// two sibling top-level <template>s get treated as one and the text
// between them leaks into the captured content. Sub-composition HTML
// authored via the documented convention always has exactly one top-level
// wrapper, so this never happens in the render pipeline — but if a future
// caller passes multi-template input, they need a different parser.
it("collapses two sibling top-level <template>s (known limitation)", () => {
const html = `<template>a</template>middle<template>b</template>`;
expect(unwrapTemplate(html)).toBe(`a</template>middle<template>b`);
});
});
24 changes: 24 additions & 0 deletions packages/engine/src/utils/htmlTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Sub-compositions wrap their content in a <template> tag. linkedom follows the
* browser contract where querySelectorAll does not descend into template content
* (it's a separate DocumentFragment), so media elements inside the wrapper are
* invisible to a plain DOM scrape. Unwrap one level of <template> if present so
* sub-composition media get parsed alongside main-composition media.
*
* Assumes at most one top-level <template> per input. The greedy match runs
* from the first `<template>` to the last `</template>`, which correctly peels
* a single wrapper (even when its content contains a nested <template>, e.g. a
* cloning template inside the composition DOM). It does NOT handle inputs with
* multiple sibling top-level <template>s — `<template>a</template>b<template>c</template>`
* collapses to `a</template>b<template>c`, which is wrong. Sub-composition
* HTML authored via the documented convention has exactly one wrapper, so
* this caveat doesn't surface in the render pipeline; callers producing
* different shapes need their own parser.
*/
export function unwrapTemplate(html: string): string {
const match = html.match(/<template[^>]*>([\s\S]*)<\/template>/i);
// Check match[1] against undefined specifically — an empty template
// produces `match[1] === ""`, which a truthiness check would treat as
// "no match found" and fall through to the original html.
return match && match[1] !== undefined ? match[1] : html;
}
15 changes: 12 additions & 3 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,9 +1233,18 @@ export async function recompileWithResolutions(
const mainImages = parseImageElements(html);

// Keep inlined sub-composition media authoritative on ID collisions.
const videos = dedupeElementsById([...mainVideos, ...subVideos]);
const audios = dedupeElementsById([...mainAudios, ...subAudios]);
const images = dedupeElementsById([...mainImages, ...subImages]);
//
// `compiled.html` has already been inlined — the original host elements with
// `[data-composition-src]` no longer exist, so `parseSubCompositions` above
// returns empty arrays. If we blindly dedupe at this point we replace the
// already-offset media from the first compile pass (in compileForRender)
// with the scene-local versions parsed from the inlined HTML, losing the
// parent offset. Only overwrite when we actually have sub-composition data
// to merge in.
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;

const remaining = compiled.unresolvedCompositions.filter(
(c) => !resolutions.some((r) => r.id === c.id),
Expand Down
10 changes: 8 additions & 2 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,12 @@ export async function executeRenderJob(
if (existing.src !== src) {
existing.src = src;
}
if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) {
// Only fill `end` when the static pipeline had nothing — otherwise the
// compiled value (which includes sub-composition host offsets) is
// authoritative. The browser's `data-end` is read from the inlined DOM
// where sub-composition clips still have scene-local end values, and
// overwriting would undo the offset applied in parseSubCompositions.
if (el.end > 0 && existing.end <= 0) {
existing.end = el.end;
}
if (
Expand Down Expand Up @@ -1224,7 +1229,8 @@ export async function executeRenderJob(
if (existing.src !== src) {
existing.src = src;
}
if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) {
// See the video branch above for why we only fill `end` when missing.
if (el.end > 0 && existing.end <= 0) {
existing.end = el.end;
}
if (
Expand Down