Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ jobs:
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
# timeouts before any real CI work starts.
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
with:
fetch-depth: 0
- uses: dorny/paths-filter@v4
id: filter
with:
token: ""
filters: |
code:
- "packages/**"
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/player-perf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ jobs:
outputs:
perf: ${{ steps.filter.outputs.perf }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the perf workflow on transient listFiles timeouts.
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
with:
fetch-depth: 0
- uses: dorny/paths-filter@v4
id: filter
with:
token: ""
filters: |
perf:
- "packages/player/**"
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ jobs:
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
# timeouts before any regression shard even starts.
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
with:
fetch-depth: 0
- uses: dorny/paths-filter@v4
id: filter
with:
token: ""
filters: |
code:
- "packages/core/**"
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/windows-render.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ jobs:
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the workflow on transient listFiles timeouts
# before the Windows render jobs even start.
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
with:
fetch-depth: 0
- uses: dorny/paths-filter@v4
id: filter
with:
token: ""
filters: |
code:
- "packages/**"
Expand Down
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
5 changes: 3 additions & 2 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import {
FRAME_FILENAME_PREFIX,
ensureCacheEntryDir,
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,7 @@ export interface ImageElement {

export function parseImageElements(html: string): ImageElement[] {
const images: ImageElement[] = [];
const { document } = parseHTML(html);
const { document } = parseHTML(unwrapTemplate(html));

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

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

it("unwraps a bare top-level template fragment", () => {
const inner = `<span>hi</span>`;
const html = `<template id="t" data-x="1">${inner}</template>`;
expect(unwrapTemplate(html)).toBe(inner);
});

it("unwraps a full document whose body only contains a 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("returns the input unchanged when the closing template tag is missing", () => {
const html = `<template><div>broken`;
expect(unwrapTemplate(html)).toBe(html);
});

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

it("preserves nested templates inside the outer wrapper", () => {
const inner = `outer-before<template>inner-content</template>outer-after`;
const html = `<template>${inner}</template>`;
expect(unwrapTemplate(html)).toBe(inner);
});

it("leaves multiple sibling templates unchanged", () => {
const html = `<template>a</template>middle<template>b</template>`;
expect(unwrapTemplate(html)).toBe(html);
});
});
50 changes: 50 additions & 0 deletions packages/engine/src/utils/htmlTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { parseHTML } from "linkedom";

function parseHTMLContent(html: string): Document {
const trimmed = html.trimStart().toLowerCase();
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
return parseHTML(html).document;
}
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
}

function getSingleMeaningfulChild(container: Element): Element | null {
let child: Element | null = null;
for (const node of Array.from(container.childNodes)) {
if (node.nodeType === 3 && !(node.textContent || "").trim()) continue;
if (node.nodeType === 8) continue;
if (node.nodeType !== 1) return null;
if (child) return null;
child = node as Element;
}
return child;
}

/**
* Sub-compositions commonly use a single top-level <template> wrapper. Parse
* the HTML and unwrap only that exact shape, rather than pattern-matching the
* raw string. This avoids both regex backtracking risk and accidental rewrites
* of inputs that contain multiple sibling templates or other top-level content.
*/
export function unwrapTemplate(html: string): string {
const lowered = html.toLowerCase();
if (!lowered.includes("<template") || !lowered.includes("</template>")) {
return html;
}

const { body } = parseHTMLContent(html);
if (!body) return html;

let container: Element = body;
const bodyWrapper = getSingleMeaningfulChild(container);
if (bodyWrapper?.tagName === "BODY") {
container = bodyWrapper;
}

const template = getSingleMeaningfulChild(container);
if (template?.tagName !== "TEMPLATE") {
return html;
}

return template.innerHTML ?? html;
}
139 changes: 139 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
compileForRender,
detectRenderModeHints,
inlineExternalScripts,
recompileWithResolutions,
} from "./htmlCompiler.js";

// ── collectExternalAssets ──────────────────────────────────────────────────
Expand Down Expand Up @@ -456,3 +457,141 @@ describe("detectRenderModeHints", () => {
}
});
});

describe("template-wrapped sub-composition media offsets", () => {
function writeTemplateWrappedProject(
hostAttrs: string,
mediaAttrs: string = 'data-start="0" data-duration="4"',
): {
projectDir: string;
indexPath: string;
} {
const projectDir = mkdtempSync(join(tmpdir(), "hf-template-offset-"));
const compositionsDir = join(projectDir, "compositions");
mkdirSync(compositionsDir, { recursive: true });
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html>
<body>
<div
id="root"
data-composition-id="root"
data-start="0"
data-width="640"
data-height="360"
data-duration="4"
>
<div
id="scene-host"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
${hostAttrs}
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["root"] = { duration: () => 4 };
</script>
</body>
</html>`,
);
writeFileSync(
join(compositionsDir, "scene.html"),
`<template id="scene-template">
<div
data-composition-id="scene"
data-start="0"
data-width="640"
data-height="360"
data-duration="4"
>
<video
id="scene-video"
src="../assets/clip.mp4"
${mediaAttrs}
data-track-index="0"
></video>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = { duration: () => 4 };
</script>
</div>
</template>`,
);

return { projectDir, indexPath: join(projectDir, "index.html") };
}

it("offsets template-wrapped media to the host start during compile", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="2" data-duration="2" data-width="640" data-height="360"',
);

const compiled = await compileForRender(projectDir, indexPath, projectDir);

expect(compiled.videos).toHaveLength(1);
expect(compiled.videos[0]).toMatchObject({
id: "scene-video",
start: 2,
end: 6,
});
expect(compiled.audios).toHaveLength(1);
expect(compiled.audios[0]).toMatchObject({
id: "scene-video-audio",
start: 2,
end: 6,
});
});

it("preserves first-pass media offsets when durations are resolved after inlining", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="2" data-width="640" data-height="360"',
);

const compiled = await compileForRender(projectDir, indexPath, projectDir);
expect(compiled.videos[0]?.start).toBe(2);

const recompiled = await recompileWithResolutions(
compiled,
[{ id: "scene-host", duration: 2 }],
projectDir,
projectDir,
);

expect(recompiled.videos).toHaveLength(1);
expect(recompiled.videos[0]).toMatchObject({
id: "scene-video",
start: 2,
end: 6,
});
expect(recompiled.audios).toHaveLength(1);
expect(recompiled.audios[0]).toMatchObject({
id: "scene-video-audio",
start: 2,
end: 6,
});
});

it("offsets scene-local media in compositions that start much later on the timeline", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="20" data-duration="6" data-width="640" data-height="360"',
'data-start="1.5" data-duration="4"',
);

const compiled = await compileForRender(projectDir, indexPath, projectDir);

expect(compiled.videos).toHaveLength(1);
expect(compiled.videos[0]).toMatchObject({
id: "scene-video",
start: 21.5,
end: 25.5,
});
expect(compiled.audios).toHaveLength(1);
expect(compiled.audios[0]).toMatchObject({
id: "scene-video-audio",
start: 21.5,
end: 25.5,
});
});
});
7 changes: 4 additions & 3 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,9 +1233,10 @@ 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]);
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
15 changes: 15 additions & 0 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CompiledComposition } from "./htmlCompiler.js";
import {
applyRenderModeHints,
extractStandaloneEntryFromIndex,
projectBrowserEndToCompositionTimeline,
writeCompiledArtifacts,
} from "./renderOrchestrator.js";
import { toExternalAssetKey } from "../utils/paths.js";
Expand Down Expand Up @@ -244,3 +245,17 @@ describe("applyRenderModeHints", () => {
expect(log.warn).not.toHaveBeenCalled();
});
});

describe("projectBrowserEndToCompositionTimeline", () => {
it("keeps end unchanged when browser and compiled starts share the same origin", () => {
expect(projectBrowserEndToCompositionTimeline(2, 2, 6)).toBe(6);
});

it("reprojects a scene-local browser end into the compiled host timeline", () => {
expect(projectBrowserEndToCompositionTimeline(4.417, 0, 85.52)).toBeCloseTo(89.937, 6);
});

it("preserves scene-local media offsets inside compositions that start much later", () => {
expect(projectBrowserEndToCompositionTimeline(21.5, 1.5, 5.5)).toBe(25.5);
});
});
Loading
Loading