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
10 changes: 5 additions & 5 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ describe("ENCODER_PRESETS", () => {
expect(ENCODER_PRESETS.draft.codec).toBe("h264");
});

it("high uses slow preset with low CRF for better quality", () => {
expect(ENCODER_PRESETS.high.preset).toBe("slow");
it("high keeps low CRF while using the standard-speed preset", () => {
expect(ENCODER_PRESETS.high.preset).toBe("medium");
expect(ENCODER_PRESETS.high.quality).toBeLessThan(ENCODER_PRESETS.standard.quality);
expect(ENCODER_PRESETS.high.codec).toBe("h264");
});
Expand Down Expand Up @@ -180,14 +180,14 @@ describe("buildEncoderArgs GPU preset mapping", () => {
expect(presetArg(args)).toBe("p4");
});

it("translates the high slow preset to NVENC p5", () => {
it("translates the high medium preset to NVENC p4", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "slow", quality: 15, useGpu: true },
{ ...baseOptions, codec: "h264", preset: "medium", quality: 15, useGpu: true },
inputArgs,
"out.mp4",
"nvenc",
);
expect(presetArg(args)).toBe("p5");
expect(presetArg(args)).toBe("p4");
});

// hevc_nvenc uses the same p1..p7 preset vocabulary as h264_nvenc, so the
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.typ
export const ENCODER_PRESETS = {
draft: { preset: "ultrafast", quality: 28, codec: "h264" as const },
standard: { preset: "medium", quality: 18, codec: "h264" as const },
high: { preset: "slow", quality: 15, codec: "h264" as const },
high: { preset: "medium", quality: 15, codec: "h264" as const },
};

export interface EncoderPreset {
Expand Down
68 changes: 66 additions & 2 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ describe("inlineExternalScripts", () => {
<script src="https://cdn.example.com/gsap.min.js"></script>
</body></html>`;
const result = await inlineExternalScripts(html);
// Both identical script tags should be fetched and replaced independently.
expect(fetchCount).toBe(2);
// Both identical script tags are replaced, but the network fetch is shared.
expect(fetchCount).toBe(1);
expect(
result.match(/\/\* inlined: https:\/\/cdn\.example\.com\/gsap\.min\.js \*\//g)?.length,
).toBe(2);
Expand All @@ -295,6 +295,70 @@ describe("inlineExternalScripts", () => {
});
});

describe("static sub-composition duration resolution", () => {
it("resolves host duration from a declared sub-composition root without a browser probe", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-static-sub-duration-"));
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-width="640" data-height="360" data-duration="8">
<div id="scene-host" data-composition-src="compositions/scene.html" data-start="2"></div>
</div>
</body></html>`,
);
writeFileSync(
join(compositionsDir, "scene.html"),
`<template>
<div data-composition-id="scene" data-width="640" data-height="360" data-duration="3">
<p>Hello</p>
</div>
</template>`,
);

const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);

expect(result.unresolvedCompositions).toEqual([]);
expect(result.html).toContain('id="scene-host"');
expect(result.html).toContain('data-duration="3"');
expect(result.html).toContain('data-end="5"');
});

it("matches unusual composition ids without selector string escaping", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-static-sub-duration-"));
const compositionsDir = join(projectDir, "compositions");
const compositionId = 'scene"\\\\host';
mkdirSync(compositionsDir, { recursive: true });

writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html><body>
<div id="root" data-composition-id="root" data-width="640" data-height="360" data-duration="8">
<div id='${compositionId}' data-composition-src="compositions/scene.html" data-start="1"></div>
</div>
</body></html>`,
);
writeFileSync(
join(compositionsDir, "scene.html"),
`<template>
<div data-composition-id='${compositionId}' data-width="640" data-height="360" data-duration="2">
<p>Hello</p>
</div>
</template>`,
);

const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);

expect(result.unresolvedCompositions).toEqual([]);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('data-end="3"');
});
});

describe("detectRenderModeHints", () => {
it("recommends screenshot mode for iframe compositions", () => {
const html = `<!DOCTYPE html>
Expand Down
109 changes: 101 additions & 8 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
return Array.from(deduped.values());
}

const externalScriptCache = new Map<
string,
{ fetcher: typeof globalThis.fetch; promise: Promise<string> }
>();
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__ */";
Expand Down Expand Up @@ -553,6 +557,65 @@ function coalesceHeadStylesAndBodyScripts(html: string): string {
return document.toString();
}

function parseDeclaredDurationFromCompositionHtml(
html: string,
compositionId: string | null | undefined,
): number | null {
let { document } = parseHTML(html);
let root = findCompositionRoot(document, compositionId);
if (!root) {
const template = document.querySelector("template");
if (template) {
document = parseHTML(template.innerHTML || "").document;
root = findCompositionRoot(document, compositionId);
}
}
if (!root) return null;

const duration = Number.parseFloat(root.getAttribute("data-duration") ?? "");
if (Number.isFinite(duration) && duration > 0) return duration;

const start = Number.parseFloat(root.getAttribute("data-start") ?? "0") || 0;
const end = Number.parseFloat(root.getAttribute("data-end") ?? "");
if (Number.isFinite(end) && end > start) return end - start;

return null;
}

function findCompositionRoot(
document: Document,
compositionId: string | null | undefined,
): Element | null {
const roots = Array.from(document.querySelectorAll("[data-composition-id]")) as Element[];
if (compositionId) {
const exactMatch = roots.find(
(root) => root.getAttribute("data-composition-id") === compositionId,
);
if (exactMatch) return exactMatch;
}
return roots[0] ?? null;
}

function resolveStaticCompositionDurations(
unresolvedCompositions: UnresolvedElement[],
subCompositions: Map<string, string>,
): ResolvedDuration[] {
const resolutions: ResolvedDuration[] = [];

for (const unresolved of unresolvedCompositions) {
const src = unresolved.compositionSrc;
if (!src) continue;
const subHtml = subCompositions.get(src);
if (!subHtml) continue;

const declaredDuration = parseDeclaredDurationFromCompositionHtml(subHtml, unresolved.id);
if (declaredDuration == null) continue;
resolutions.push({ id: unresolved.id, duration: declaredDuration });
}

return resolutions;
}

/**
* Inline sub-composition HTML into the main document, mirroring what the
* bundler's step 6 does. For each host element with `data-composition-src`:
Expand Down Expand Up @@ -828,13 +891,7 @@ export async function inlineExternalScripts(html: string): Promise<string> {
if (externalScripts.length === 0) return html;

const downloads = await Promise.allSettled(
externalScripts.map(async ({ src }) => {
const response = await fetch(src, {
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`HTTP ${response.status} for ${src}`);
return { src, text: await response.text() };
}),
externalScripts.map(async ({ src }) => ({ src, text: await fetchExternalScriptText(src) })),
);

for (let i = 0; i < downloads.length; i++) {
Expand Down Expand Up @@ -865,6 +922,30 @@ export async function inlineExternalScripts(html: string): Promise<string> {
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}

async function fetchExternalScriptText(src: string): Promise<string> {
const cached = externalScriptCache.get(src);
if (cached && cached.fetcher === globalThis.fetch) return cached.promise;

const download = (async () => {
const response = await fetch(src, {
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`HTTP ${response.status} for ${src}`);
return response.text();
})();

externalScriptCache.set(src, { fetcher: globalThis.fetch, promise: download });
try {
return await download;
} catch (error) {
const current = externalScriptCache.get(src);
if (current?.promise === download) {
externalScriptCache.delete(src);
}
throw error;
}
}

/**
* Scan compiled HTML for asset references that resolve outside projectDir.
* For each, map the normalized in-HTML path to the real filesystem path so
Expand Down Expand Up @@ -964,7 +1045,7 @@ export async function compileForRender(
downloadDir: string,
): Promise<CompiledComposition> {
const rawHtml = readFileSync(htmlPath, "utf-8");
const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
let { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
rawHtml,
projectDir,
downloadDir,
Expand All @@ -978,6 +1059,18 @@ export async function compileForRender(
subCompositions,
} = await parseSubCompositions(compiledHtml, projectDir, downloadDir);

const staticCompositionResolutions = resolveStaticCompositionDurations(
unresolvedCompositions,
subCompositions,
);
if (staticCompositionResolutions.length > 0) {
compiledHtml = injectDurations(compiledHtml, staticCompositionResolutions);
const resolvedIds = new Set(staticCompositionResolutions.map((resolution) => resolution.id));
unresolvedCompositions = unresolvedCompositions.filter(
(composition) => !resolvedIds.has(composition.id),
);
}

// Ensure the HTML is a full document before inlining sub-compositions.
// When index.html is a fragment (no <html>/<head>/<body>), linkedom.parseHTML()
// returns a document with null head/body, which causes inlineSubCompositions to
Expand Down
Loading