diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index bc3700ff8..26cc01edb 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -391,27 +391,23 @@ export function lintHyperframeHtml( } } - // #3.7: Fabricated inline base64 media — CRITICAL - // Inline base64 audio/video data is almost always fabricated garbage that - // won't play. Real audio files are 100KB+ when base64-encoded. + // #3.7: Inline base64 audio/video — PROHIBITED + // Base64 audio/video bloats file size and breaks rendering. Use URLs or relative paths. { 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; 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 uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size; const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4); const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000); - // Any embedded base64 audio is suspicious — real audio should be a file pushFinding({ - code: "fabricated_inline_media", - severity: isSuspicious ? "error" : "warning", - message: isSuspicious - ? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.` - : `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`, - fixHint: "Remove the data: URI and use a real media file URL instead.", + code: "base64_media_prohibited", + severity: "error", + 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.`, + fixHint: + "Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.", snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."), }); } diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index a439ae727..586a10884 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -69,6 +69,7 @@ interface RenderInput { workers?: number; useGpu: boolean; debug: boolean; + entryFile?: string; } interface PreparedRenderInput { @@ -91,7 +92,12 @@ function parseRenderOptions(body: Record): Omit 0 + ? body.entryFile.trim() + : undefined; + + return { outputPath, fps, quality, workers, useGpu, debug, entryFile }; } async function prepareRenderBody( @@ -104,8 +110,9 @@ async function prepareRenderBody( if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) { return { error: `Project directory not found: ${absProjectDir}` }; } - if (!existsSync(resolve(absProjectDir, "index.html"))) { - return { error: `No index.html in project directory: ${absProjectDir}` }; + const entry = options.entryFile || "index.html"; + if (!existsSync(resolve(absProjectDir, entry))) { + return { error: `Entry file "${entry}" not found in project directory: ${absProjectDir}` }; } return { prepared: { input: { projectDir: absProjectDir, ...options } } }; } @@ -317,6 +324,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle workers: input.workers, useGpu: input.useGpu, debug: input.debug, + entryFile: input.entryFile, logger: log, }); @@ -428,6 +436,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle workers: input.workers, useGpu: input.useGpu, debug: input.debug, + entryFile: input.entryFile, logger: log, }); const abortController = new AbortController(); diff --git a/packages/producer/src/services/hyperframeRuntimeLoader.ts b/packages/producer/src/services/hyperframeRuntimeLoader.ts index c0943eb8b..64e72f6a6 100644 --- a/packages/producer/src/services/hyperframeRuntimeLoader.ts +++ b/packages/producer/src/services/hyperframeRuntimeLoader.ts @@ -10,6 +10,10 @@ const MODULE_RELATIVE_MANIFEST_PATH = resolve( "../../../core/dist/hyperframe.manifest.json", ); 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"), ]; diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts new file mode 100644 index 000000000..95a8b471e --- /dev/null +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -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 = ` + + + + + + +
+
+
+
+ +`; + + 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(""); + }); + + it("matches normalized data-composition-src paths", () => { + const indexHtml = ` + + +
+
+
+ +`; + + 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 = ` + + +
+
+
+ +`; + + const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/outro.html"); + + expect(extracted).toBeNull(); + }); +}); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 1205e8715..f28b75e82 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -13,7 +13,16 @@ * 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 { type EngineConfig, resolveConfig, @@ -93,6 +102,8 @@ export interface RenderConfig { workers?: number; useGpu?: 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. */ producerConfig?: EngineConfig; /** 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 */ + +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( job: RenderJob, projectDir: string, @@ -319,9 +375,41 @@ export async function executeRenderJob( 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(); + // If entryFile is a sub-composition (