mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(producer): support entryFile for rendering individual compositions (#42)
## Changes
- Add optional `entryFile` parameter to render API endpoints (`/v1/render` and `/v1/render-stream`)
- Enable rendering individual sub-compositions by extracting them from index.html context when the entry file is a `<template>` wrapper
- Change base64 audio/video linting from detecting "fabricated" media to prohibiting all inline base64 media
- Add manifest path resolution for bundled producer deployments
## API Changes
- `RenderConfig` — new optional `entryFile` field for specifying HTML file to render
- `server.ts` — parses `entryFile` from request body, validates file exists in project directory
- `executeRenderJob` — uses `entryFile` instead of hardcoded `"index.html"`
## Template Extraction
- `extractStandaloneEntryFromIndex` — extracts sub-composition hosts from index.html and creates standalone render context
- Handles `<template>` entry files by finding matching `data-composition-src` in index.html and isolating that host
- Resets `data-start` to 0 for standalone rendering
## Linting Updates
- Change rule #3.7 from detecting "fabricated" base64 media to prohibiting all inline base64 audio/video
- Lower detection threshold from 100+ to 20+ base64 characters
- All base64 media now triggers error severity with clearer messaging about file size bloat
## Usage
```json
POST /v1/render-stream
{ "projectDir": "/path/to/project", "entryFile": "compositions/intro.html" }
```
Omit `entryFile` for default behavior (renders `index.html`).
This commit is contained in:
@@ -391,27 +391,23 @@ export function lintHyperframeHtml(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #3.7: Fabricated inline base64 media — CRITICAL
|
// #3.7: Inline base64 audio/video — PROHIBITED
|
||||||
// Inline base64 audio/video data is almost always fabricated garbage that
|
// Base64 audio/video bloats file size and breaks rendering. Use URLs or relative paths.
|
||||||
// won't play. Real audio files are 100KB+ when base64-encoded.
|
|
||||||
{
|
{
|
||||||
const base64MediaRe =
|
const base64MediaRe =
|
||||||
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
|
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
|
||||||
let b64Match: RegExpExecArray | null;
|
let b64Match: RegExpExecArray | null;
|
||||||
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
||||||
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
|
|
||||||
const sample = (b64Match[2] || "").slice(0, 200);
|
const sample = (b64Match[2] || "").slice(0, 200);
|
||||||
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
||||||
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
||||||
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
||||||
// Any embedded base64 audio is suspicious — real audio should be a file
|
|
||||||
pushFinding({
|
pushFinding({
|
||||||
code: "fabricated_inline_media",
|
code: "base64_media_prohibited",
|
||||||
severity: isSuspicious ? "error" : "warning",
|
severity: "error",
|
||||||
message: isSuspicious
|
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
|
||||||
? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.`
|
fixHint:
|
||||||
: `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`,
|
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
|
||||||
fixHint: "Remove the data: URI and use a real media file URL instead.",
|
|
||||||
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface RenderInput {
|
|||||||
workers?: number;
|
workers?: number;
|
||||||
useGpu: boolean;
|
useGpu: boolean;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
entryFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedRenderInput {
|
interface PreparedRenderInput {
|
||||||
@@ -91,7 +92,12 @@ function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "p
|
|||||||
? body.output
|
? body.output
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return { outputPath, fps, quality, workers, useGpu, debug };
|
const entryFile =
|
||||||
|
typeof body.entryFile === "string" && body.entryFile.trim().length > 0
|
||||||
|
? body.entryFile.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return { outputPath, fps, quality, workers, useGpu, debug, entryFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareRenderBody(
|
async function prepareRenderBody(
|
||||||
@@ -104,8 +110,9 @@ async function prepareRenderBody(
|
|||||||
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
||||||
return { error: `Project directory not found: ${absProjectDir}` };
|
return { error: `Project directory not found: ${absProjectDir}` };
|
||||||
}
|
}
|
||||||
if (!existsSync(resolve(absProjectDir, "index.html"))) {
|
const entry = options.entryFile || "index.html";
|
||||||
return { error: `No index.html in project directory: ${absProjectDir}` };
|
if (!existsSync(resolve(absProjectDir, entry))) {
|
||||||
|
return { error: `Entry file "${entry}" not found in project directory: ${absProjectDir}` };
|
||||||
}
|
}
|
||||||
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
|
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
|
||||||
}
|
}
|
||||||
@@ -317,6 +324,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
workers: input.workers,
|
workers: input.workers,
|
||||||
useGpu: input.useGpu,
|
useGpu: input.useGpu,
|
||||||
debug: input.debug,
|
debug: input.debug,
|
||||||
|
entryFile: input.entryFile,
|
||||||
logger: log,
|
logger: log,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,6 +436,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
workers: input.workers,
|
workers: input.workers,
|
||||||
useGpu: input.useGpu,
|
useGpu: input.useGpu,
|
||||||
debug: input.debug,
|
debug: input.debug,
|
||||||
|
entryFile: input.entryFile,
|
||||||
logger: log,
|
logger: log,
|
||||||
});
|
});
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const MODULE_RELATIVE_MANIFEST_PATH = resolve(
|
|||||||
"../../../core/dist/hyperframe.manifest.json",
|
"../../../core/dist/hyperframe.manifest.json",
|
||||||
);
|
);
|
||||||
const CWD_RELATIVE_MANIFEST_PATHS = [
|
const CWD_RELATIVE_MANIFEST_PATHS = [
|
||||||
|
// When bundled to a single file (dist/public-server.js), the manifest
|
||||||
|
// is copied as a sibling by build.mjs
|
||||||
|
resolve(PRODUCER_DIR, "hyperframe.manifest.json"),
|
||||||
|
resolve(process.cwd(), "packages/core/dist/hyperframe.manifest.json"),
|
||||||
resolve(process.cwd(), "../core/dist/hyperframe.manifest.json"),
|
resolve(process.cwd(), "../core/dist/hyperframe.manifest.json"),
|
||||||
resolve(process.cwd(), "core/dist/hyperframe.manifest.json"),
|
resolve(process.cwd(), "core/dist/hyperframe.manifest.json"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractStandaloneEntryFromIndex } from "./renderOrchestrator.js";
|
||||||
|
|
||||||
|
describe("extractStandaloneEntryFromIndex", () => {
|
||||||
|
it("reuses the index wrapper and keeps only the requested composition host", () => {
|
||||||
|
const indexHtml = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>body { background: #111; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="main" data-composition-id="root" data-width="1920" data-height="1080">
|
||||||
|
<div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="5"></div>
|
||||||
|
<div id="outro" data-composition-id="outro" data-composition-src="compositions/outro.html" data-start="12"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/outro.html");
|
||||||
|
|
||||||
|
expect(extracted).toContain('data-composition-id="root"');
|
||||||
|
expect(extracted).toContain('id="outro"');
|
||||||
|
expect(extracted).toContain('data-composition-src="compositions/outro.html"');
|
||||||
|
expect(extracted).toContain('data-start="0"');
|
||||||
|
expect(extracted).not.toContain('id="intro"');
|
||||||
|
expect(extracted).toContain("<style>body { background: #111; }</style>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches normalized data-composition-src paths", () => {
|
||||||
|
const indexHtml = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
||||||
|
<div id="intro" data-composition-id="intro" data-composition-src="./compositions/intro.html" data-start="3"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/intro.html");
|
||||||
|
|
||||||
|
expect(extracted).not.toBeNull();
|
||||||
|
expect(extracted).toContain('data-start="0"');
|
||||||
|
expect(extracted).toContain('data-composition-src="./compositions/intro.html"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when index.html does not mount the requested entry file", () => {
|
||||||
|
const indexHtml = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
||||||
|
<div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/outro.html");
|
||||||
|
|
||||||
|
expect(extracted).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,16 @@
|
|||||||
* full context, and failures produce a diagnostic summary.
|
* full context, and failures produce a diagnostic summary.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync, copyFileSync, appendFileSync } from "fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
rmSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
copyFileSync,
|
||||||
|
appendFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
import {
|
import {
|
||||||
type EngineConfig,
|
type EngineConfig,
|
||||||
resolveConfig,
|
resolveConfig,
|
||||||
@@ -93,6 +102,8 @@ export interface RenderConfig {
|
|||||||
workers?: number;
|
workers?: number;
|
||||||
useGpu?: boolean;
|
useGpu?: boolean;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
|
/** Entry HTML file relative to projectDir. Defaults to "index.html". */
|
||||||
|
entryFile?: string;
|
||||||
/** Full producer config. When provided, env vars are not read. */
|
/** Full producer config. When provided, env vars are not read. */
|
||||||
producerConfig?: EngineConfig;
|
producerConfig?: EngineConfig;
|
||||||
/** Custom logger. Defaults to console-based defaultLogger. */
|
/** Custom logger. Defaults to console-based defaultLogger. */
|
||||||
@@ -272,9 +283,54 @@ export function createRenderJob(config: RenderConfig): RenderJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCompositionSrcPath(srcPath: string): string {
|
||||||
|
return srcPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main render pipeline
|
* Main render pipeline
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export function extractStandaloneEntryFromIndex(
|
||||||
|
indexHtml: string,
|
||||||
|
entryFile: string,
|
||||||
|
): string | null {
|
||||||
|
const normalizedEntryFile = normalizeCompositionSrcPath(entryFile);
|
||||||
|
const { document } = parseHTML(indexHtml);
|
||||||
|
const body = document.querySelector("body");
|
||||||
|
if (!body) return null;
|
||||||
|
|
||||||
|
const hosts = Array.from(document.querySelectorAll("[data-composition-src]")) as Element[];
|
||||||
|
const host = hosts.find(
|
||||||
|
(candidate) =>
|
||||||
|
normalizeCompositionSrcPath(candidate.getAttribute("data-composition-src") || "") ===
|
||||||
|
normalizedEntryFile,
|
||||||
|
);
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const root =
|
||||||
|
(Array.from(body.children) as Element[]).find((candidate) =>
|
||||||
|
candidate.hasAttribute("data-composition-id"),
|
||||||
|
) ?? null;
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const hostClone = host.cloneNode(true) as Element;
|
||||||
|
hostClone.setAttribute("data-start", "0");
|
||||||
|
|
||||||
|
body.innerHTML = "";
|
||||||
|
|
||||||
|
if (root === host) {
|
||||||
|
body.appendChild(hostClone);
|
||||||
|
return document.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootClone = root.cloneNode(false) as Element;
|
||||||
|
rootClone.appendChild(hostClone);
|
||||||
|
body.appendChild(rootClone);
|
||||||
|
|
||||||
|
return document.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeRenderJob(
|
export async function executeRenderJob(
|
||||||
job: RenderJob,
|
job: RenderJob,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
@@ -319,9 +375,41 @@ export async function executeRenderJob(
|
|||||||
restoreLogger = installDebugLogger(logPath, log);
|
restoreLogger = installDebugLogger(logPath, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlPath = join(projectDir, "index.html");
|
const entryFile = job.config.entryFile || "index.html";
|
||||||
|
let htmlPath = join(projectDir, entryFile);
|
||||||
|
if (!existsSync(htmlPath)) {
|
||||||
|
throw new Error(`Entry file not found: ${htmlPath}`);
|
||||||
|
}
|
||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
|
||||||
|
// If entryFile is a sub-composition (<template> wrapper), reuse the real
|
||||||
|
// index.html shell and isolate the matching host instead of fabricating
|
||||||
|
// a new standalone document.
|
||||||
|
const rawEntry = readFileSync(htmlPath, "utf-8");
|
||||||
|
if (entryFile !== "index.html" && rawEntry.trimStart().startsWith("<template")) {
|
||||||
|
const wrapperPath = join(workDir, "standalone-entry.html");
|
||||||
|
const projectIndexPath = join(projectDir, "index.html");
|
||||||
|
if (!existsSync(projectIndexPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Template entry file "${entryFile}" requires a project index.html to extract its render shell.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const standaloneHtml = extractStandaloneEntryFromIndex(
|
||||||
|
readFileSync(projectIndexPath, "utf-8"),
|
||||||
|
entryFile,
|
||||||
|
);
|
||||||
|
if (!standaloneHtml) {
|
||||||
|
throw new Error(
|
||||||
|
`Entry file "${entryFile}" is not mounted from index.html via data-composition-src, so it cannot be rendered independently.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(wrapperPath, standaloneHtml, "utf-8");
|
||||||
|
htmlPath = wrapperPath;
|
||||||
|
log.info("Extracted standalone entry from index.html host context", {
|
||||||
|
entryFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stage 1: Compile ─────────────────────────────────────────────────
|
// ── Stage 1: Compile ─────────────────────────────────────────────────
|
||||||
const stage1Start = Date.now();
|
const stage1Start = Date.now();
|
||||||
updateJobStatus(job, "preprocessing", "Compiling composition", 5, onProgress);
|
updateJobStatus(job, "preprocessing", "Compiling composition", 5, onProgress);
|
||||||
@@ -332,6 +420,15 @@ export async function executeRenderJob(
|
|||||||
perfStages.compileOnlyMs = Date.now() - compileStart;
|
perfStages.compileOnlyMs = Date.now() - compileStart;
|
||||||
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
|
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
|
||||||
|
|
||||||
|
log.info("Compiled composition metadata", {
|
||||||
|
entryFile,
|
||||||
|
staticDuration: compiled.staticDuration,
|
||||||
|
width: compiled.width,
|
||||||
|
height: compiled.height,
|
||||||
|
videoCount: compiled.videos.length,
|
||||||
|
audioCount: compiled.audios.length,
|
||||||
|
});
|
||||||
|
|
||||||
const composition: CompositionMetadata = {
|
const composition: CompositionMetadata = {
|
||||||
duration: compiled.staticDuration,
|
duration: compiled.staticDuration,
|
||||||
videos: compiled.videos,
|
videos: compiled.videos,
|
||||||
@@ -379,7 +476,15 @@ export async function executeRenderJob(
|
|||||||
if (composition.duration <= 0) {
|
if (composition.duration <= 0) {
|
||||||
const discoveredDuration = await getCompositionDuration(probeSession);
|
const discoveredDuration = await getCompositionDuration(probeSession);
|
||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
log.info("Probed composition duration from browser", {
|
||||||
|
discoveredDuration,
|
||||||
|
staticDuration: compiled.staticDuration,
|
||||||
|
});
|
||||||
composition.duration = discoveredDuration;
|
composition.duration = discoveredDuration;
|
||||||
|
} else {
|
||||||
|
log.info("Using static duration from data-duration attribute", {
|
||||||
|
duration: composition.duration,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve unresolved composition durations via window.__timelines
|
// Resolve unresolved composition durations via window.__timelines
|
||||||
|
|||||||
Reference in New Issue
Block a user