fix(render): surface structured outcomes (#2153)

This commit is contained in:
James Russo
2026-07-13 19:07:39 -04:00
committed by GitHub
parent 6892b62662
commit cb69d3fe00
29 changed files with 1137 additions and 205 deletions
+30 -1
View File
@@ -80,7 +80,7 @@ vi.mock("../utils/producer.js", () => ({
}),
createRenderJob: vi.fn((config: Record<string, unknown>) => {
producerState.createdJobs.push(config);
return { config, progress: 100 };
return { config, progress: 100, outcome: "completed", warnings: [] };
}),
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
})),
@@ -413,6 +413,35 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.debug).toBe(true);
});
it("defaults to best-effort readiness", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(producerState.createdJobs[0]?.strictness).toBe("best-effort");
});
it("forwards an explicit strict readiness opt-in", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
bestEffort: false,
});
expect(producerState.createdJobs[0]?.strictness).toBe("strict");
});
it("omits variables from createRenderJob when not provided", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
+28 -1
View File
@@ -267,6 +267,12 @@ export default defineCommand({
"Write full render diagnostics and keep intermediate artifacts under the producer .debug directory.",
default: false,
},
"best-effort": {
type: "boolean",
description:
"Allow output with structured capture-readiness warnings (default). Use --no-best-effort to fail on missing or unready media.",
default: true,
},
strict: {
type: "boolean",
description: "Fail render on lint errors",
@@ -622,6 +628,7 @@ export default defineCommand({
const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg);
const quiet = args.quiet ?? false;
const debug = args.debug ?? false;
const bestEffort = args["best-effort"] ?? true;
const batchJson = args.json ?? false;
const effectiveQuiet = quiet || (batchPath != null && batchJson);
const strictAll = args["strict-all"] ?? false;
@@ -902,6 +909,7 @@ export default defineCommand({
protocolTimeout,
playerReadyTimeout,
debug,
bestEffort,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
@@ -960,6 +968,7 @@ export default defineCommand({
videoFrameFormat,
quiet,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
@@ -988,6 +997,7 @@ export default defineCommand({
quiet,
browserPath,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
@@ -1006,6 +1016,8 @@ export default defineCommand({
export interface SingleRenderResult {
durationMs?: number;
renderTimeMs: number;
outcome?: "completed" | "completed_with_warnings";
warnings?: Array<{ code: string; message: string }>;
}
export function renderLintContinuationHint(strictErrors: boolean): string {
@@ -1036,6 +1048,7 @@ interface RenderOptions {
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
entryFile?: string;
@@ -1370,6 +1383,7 @@ async function renderDocker(
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,
bestEffort: options.bestEffort,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
@@ -1490,6 +1504,7 @@ export async function renderLocal(
entryFile: options.entryFile,
outputResolution: options.outputResolution,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
});
const onProgress = options.quiet
@@ -1515,6 +1530,11 @@ export async function renderLocal(
maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet);
const elapsed = Date.now() - startTime;
if (job.outcome === "completed_with_warnings") {
for (const warning of job.warnings) {
console.warn(c.warn(` [${warning.code}] ${warning.message}`));
}
}
trackRenderMetrics(job, elapsed, options, false);
printRenderComplete(
outputPath,
@@ -1534,7 +1554,14 @@ export async function renderLocal(
const durationMs = job.perfSummary
? Math.round(job.perfSummary.compositionDurationSeconds * 1000)
: undefined;
return { renderTimeMs: elapsed, durationMs };
const outcome =
job.outcome === "completed_with_warnings" ? "completed_with_warnings" : "completed";
return {
renderTimeMs: elapsed,
durationMs,
outcome,
warnings: job.warnings.map((warning) => ({ code: warning.code, message: warning.message })),
};
}
type UnrefableTimer = {
@@ -280,6 +280,7 @@ describe("studioRenderTelemetry", () => {
progress: 25,
currentStage: "Starting frame capture",
createdAt: new Date(),
warnings: [],
errorDetails: {
message: "Navigation timeout of 60000 ms exceeded",
elapsedMs: 60_001,
@@ -172,6 +172,7 @@ describe("buildDockerRunArgs", () => {
videoFrameFormat: "png",
quiet: true,
debug: true,
bestEffort: false,
entryFile: "compositions/intro.html",
experimentalFastCapture: true,
},
@@ -191,6 +192,7 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("png");
expect(args).toContain("--quiet");
expect(args).toContain("--debug");
expect(args).toContain("--no-best-effort");
expect(args).toContain("--gpu");
expect(args).toContain("--no-browser-gpu");
expect(args).toContain("--hdr");
@@ -199,6 +201,21 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("--experimental-fast-capture");
});
it("forwards only an explicit strict-readiness opt-in", () => {
const compatible = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, bestEffort: true },
});
expect(compatible).not.toContain("--best-effort");
expect(compatible).not.toContain("--no-best-effort");
const strict = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, bestEffort: false },
});
expect(strict).toContain("--no-best-effort");
});
it("forwards --experimental-fast-capture only when enabled", () => {
const on = buildDockerRunArgs({
...FIXED_INPUT,
+4
View File
@@ -51,6 +51,7 @@ export interface DockerRenderOptions {
videoFrameFormat?: "auto" | "jpg" | "png";
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
@@ -136,6 +137,9 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
: []),
...(options.quiet ? ["--quiet"] : []),
...(options.debug ? ["--debug"] : []),
// The in-container CLI is best-effort by default. Only forward the
// explicit strict opt-in so Docker and local renders cannot drift.
...(options.bestEffort === false ? ["--no-best-effort"] : []),
...(options.gpu ? ["--gpu"] : []),
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
...(options.hdrMode === "force-hdr" ? ["--hdr"] : []),
@@ -582,7 +582,7 @@ window.__utilsMarker = gsap.utils.marker;
expect(errorSpy).not.toHaveBeenCalled();
});
it("reads remapped timeline registry accessors with the original target receiver", () => {
it("reads and dual-publishes remapped timelines without replacing the registry", () => {
let timeline = "initial";
const timelineRegistry = {
get host() {
@@ -633,6 +633,8 @@ window.__afterTimeline = window.__timelines.scene;
expect(fakeWindow.__beforeTimeline).toBe("initial");
expect(fakeWindow.__afterTimeline).toBe("updated");
expect(Reflect.get(timelineRegistry, "scene")).toBe("updated");
expect(fakeWindow.__timelines).toBe(timelineRegistry);
expect(errorSpy).not.toHaveBeenCalled();
});
@@ -408,10 +408,25 @@ export function wrapScopedCompositionScript(
if (!__hfTimelineRegistryProxy) {
__hfTimelineRegistryProxy = new Proxy(window.__timelines, {
get: function(target, prop, receiver) {
return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, target);
if (prop !== __hfCompId) {
return Reflect.get(target, prop, target);
}
var authoredValue = Reflect.get(target, prop, target);
return authoredValue === undefined
? Reflect.get(target, __hfTimelineCompId, target)
: authoredValue;
},
set: function(target, prop, value, receiver) {
return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, target);
if (prop !== __hfCompId) {
return Reflect.set(target, prop, value, target);
}
// The authored node remains in the compiled DOM when its local id
// differs from the runtime mount id, so readiness legitimately sees
// both compositions. Publish the same timeline under both identities
// instead of replacing one with the other.
var authoredSet = Reflect.set(target, __hfCompId, value, target);
var runtimeSet = Reflect.set(target, __hfTimelineCompId, value, target);
return authoredSet && runtimeSet;
},
});
}
@@ -435,6 +450,11 @@ export function wrapScopedCompositionScript(
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
// Common authoring boilerplate assigns the registry back to
// itself (window.__timelines = window.__timelines || {}). The
// getter above returns our proxy; do not replace the canonical
// registry with that proxy or later wrappers will stack proxies.
if (value === __hfTimelineRegistryProxy) return true;
target.__timelines = value || {};
__hfTimelineRegistryProxy = null;
return true;
@@ -142,6 +142,32 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
expect(wrappedScript).toContain('"intro"');
});
it("maps a template-local timeline id onto a differently named mount", () => {
const document = makeHostDocument("captions-comp");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const captionsHtml = `<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>[data-composition-id="captions"] { opacity: 1; }</style>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["captions"] = { duration: 4 };
</script>
</div>
</template>`;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => captionsHtml,
parseHtml: (html) => parseHTML(html).document,
});
expect(host.getAttribute("data-composition-id")).toBe("captions-comp");
expect(host.querySelector('[data-composition-id="captions"]')).not.toBeNull();
expect(result.styles.join("\n")).toContain('[data-composition-id="captions-comp"]');
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain('var __hfCompId = "captions"');
expect(wrappedScript).toContain('var __hfTimelineCompId = "captions-comp"');
});
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -228,13 +228,21 @@ export function inlineSubCompositions(
continue;
}
// Find the inner composition root
// Keep structural flattening tied to an exact mount-id match. A template
// may intentionally use a different local id (for example, a
// `captions-comp` host mounting a `captions` template); flattening that
// fallback root changes the compiled DOM and can invalidate selectors and
// regression goldens. Discover it separately so script timeline
// registration can still map the authored id onto the runtime mount id.
const innerRoot = compId
? queryByAttr(contentDoc, "data-composition-id", compId)
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredCompositionRoot = innerRoot ?? contentDoc.querySelector("[data-composition-id]");
const inferredCompId =
authoredCompositionRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const scopeCompId = compId || inferredCompId;
const scriptCompositionId = inferredCompId || scopeCompId;
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
// Variable merging (bundler feature). Read declared defaults from the
@@ -312,13 +320,13 @@ export function inlineSubCompositions(
}
scriptItems.push({ kind: "external", src: externalSrc });
} else {
const wrappedScript = scopeCompId
const wrappedScript = scriptCompositionId
? wrapScopedCompositionScript(
s.textContent || "",
scopeCompId,
scriptCompositionId,
scriptErrorLabel,
runtimeScope || undefined,
runtimeCompId || scopeCompId,
runtimeCompId || scopeCompId || scriptCompositionId,
authoredRootId,
)
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
+2
View File
@@ -40,6 +40,8 @@ export type {
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
CaptureWarning,
CaptureWarningCode,
SubTimelineWaitOutcome,
} from "./types.js";
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { Page } from "puppeteer-core";
import { collectMediaReadinessWarnings } from "./frameCapture.js";
function makePage(input: {
images?: Array<{ src: string; complete: boolean; naturalWidth: number }>;
media?: Array<{
id?: string;
tagName: "VIDEO" | "AUDIO";
src: string;
readyState: number;
networkState?: number;
error?: unknown;
}>;
}): Page {
return {
evaluate: async (fn: (skipIds: readonly string[]) => unknown, skipIds: readonly string[]) => {
const previousDocument = Reflect.get(globalThis, "document");
const previousMedia = Reflect.get(globalThis, "HTMLMediaElement");
Reflect.set(globalThis, "HTMLMediaElement", {
NETWORK_NO_SOURCE: 3,
HAVE_CURRENT_DATA: 2,
});
Reflect.set(globalThis, "document", {
querySelectorAll: (selector: string) => {
if (selector === "img") {
return (input.images ?? []).map((image) => ({
...image,
getAttribute: (name: string) => (name === "src" ? image.src : null),
}));
}
const media =
selector === "video"
? (input.media ?? []).filter((element) => element.tagName === "VIDEO")
: (input.media ?? []);
return media.map((media) => ({
id: media.id ?? "",
tagName: media.tagName,
currentSrc: media.src,
readyState: media.readyState,
networkState: media.networkState ?? 1,
error: media.error ?? null,
getAttribute: (name: string) => (name === "src" ? media.src : null),
}));
},
});
try {
return await fn(skipIds);
} finally {
Reflect.set(globalThis, "document", previousDocument);
Reflect.set(globalThis, "HTMLMediaElement", previousMedia);
}
},
} as unknown as Page;
}
describe("collectMediaReadinessWarnings", () => {
it("returns stable warnings for visual media and ignores out-of-band audio", async () => {
const page = makePage({
images: [
{ src: "/pending.png", complete: false, naturalWidth: 0 },
{ src: "/broken.png", complete: true, naturalWidth: 0 },
],
media: [
{ tagName: "VIDEO", src: "/broken.mp4", readyState: 0, error: new Error("decode") },
{ tagName: "AUDIO", src: "/pending.mp3", readyState: 1 },
{ id: "injected", tagName: "VIDEO", src: "/injected.mp4", readyState: 0 },
],
});
const warnings = await collectMediaReadinessWarnings(page, ["injected"], 45000);
expect(warnings.map((warning) => [warning.code, warning.details?.mediaType])).toEqual([
["media_readiness_timeout", "image"],
["media_load_failed", "image"],
["media_load_failed", "video"],
]);
expect(warnings.every((warning) => warning.details?.timeoutMs === 45000)).toBe(true);
});
it("returns no warnings when every relevant resource is ready", async () => {
const page = makePage({
images: [{ src: "/ready.png", complete: true, naturalWidth: 100 }],
media: [
{ tagName: "VIDEO", src: "/ready.mp4", readyState: 2 },
{ tagName: "AUDIO", src: "/ready.mp3", readyState: 2 },
],
});
await expect(collectMediaReadinessWarnings(page, [], 1000)).resolves.toEqual([]);
});
});
@@ -50,6 +50,7 @@ import type {
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
CaptureWarning,
SubTimelineWaitOutcome,
} from "../types.js";
@@ -106,6 +107,8 @@ export interface CaptureSession {
scriptLoadFailures: string[];
/** Outcome of the sub-composition timeline wait: ready | timeout | script_failure. */
subTimelineWaitOutcome?: SubTimelineWaitOutcome;
/** Structured readiness warnings surfaced to the producer's render policy. */
warnings: CaptureWarning[];
initTelemetry?: {
initDurationMs: number;
tweenCount: number;
@@ -946,6 +949,7 @@ export async function createCaptureSession(
isInitialized: false,
browserConsoleBuffer: [],
scriptLoadFailures: [],
warnings: [],
capturePerf: {
frames: 0,
seekMs: 0,
@@ -1325,6 +1329,105 @@ export async function pollImagesReady(
return check();
}
type MediaReadinessSnapshot = {
pendingImages: string[];
failedImages: string[];
pendingVideos: string[];
failedVideos: string[];
};
/** @internal exported for contract testing. */
export async function collectMediaReadinessWarnings(
page: Page,
skipIds: readonly string[],
timeoutMs: number,
): Promise<CaptureWarning[]> {
const snapshot = await page.evaluate((skipIdList: readonly string[]): MediaReadinessSnapshot => {
const skipped = new Set(skipIdList);
const result: MediaReadinessSnapshot = {
pendingImages: [],
failedImages: [],
pendingVideos: [],
failedVideos: [],
};
for (const img of document.querySelectorAll("img")) {
const src = img.getAttribute("src") || "";
if (!src || src.startsWith("data:")) continue;
if (!img.complete) result.pendingImages.push(img.src || src);
else if (img.naturalWidth <= 0) result.failedImages.push(img.src || src);
}
// Browser media readiness is a frame-capture requirement only for video.
// Audio is extracted and mixed out of band by the producer, so an idle
// DOM <audio> element can legitimately remain at HAVE_NOTHING here. The
// producer reports actual extraction/mix failures as audio_processing_failed.
for (const media of document.querySelectorAll("video")) {
const mediaElement = media as HTMLMediaElement;
if (skipped.has(mediaElement.id)) continue;
const src = mediaElement.currentSrc || mediaElement.getAttribute("src") || "(no src)";
const failed =
Boolean(mediaElement.error) ||
mediaElement.networkState === HTMLMediaElement.NETWORK_NO_SOURCE;
const pending = !failed && mediaElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA;
if (failed) result.failedVideos.push(src);
else if (pending) result.pendingVideos.push(src);
}
return result;
}, skipIds);
const warnings: CaptureWarning[] = [];
const append = (
code: CaptureWarning["code"],
mediaType: "image" | "video" | "audio",
sources: string[],
) => {
if (sources.length === 0) return;
const timedOut = code === "media_readiness_timeout";
warnings.push({
code,
message: timedOut
? `${mediaType} media did not become capture-ready within ${timeoutMs}ms`
: `${mediaType} media failed to load before capture`,
details: { mediaType, sources: [...new Set(sources)].sort(), timeoutMs },
});
};
append("media_readiness_timeout", "image", snapshot.pendingImages);
append("media_load_failed", "image", snapshot.failedImages);
append("media_readiness_timeout", "video", snapshot.pendingVideos);
append("media_load_failed", "video", snapshot.failedVideos);
return warnings;
}
function recordCaptureWarnings(session: CaptureSession, warnings: readonly CaptureWarning[]): void {
for (const warning of warnings) {
const key = `${warning.code}:${JSON.stringify(warning.details ?? {})}`;
if (
session.warnings.some(
(existing) => `${existing.code}:${JSON.stringify(existing.details ?? {})}` === key,
)
) {
continue;
}
session.warnings.push(warning);
console.warn(`[FrameCapture:${warning.code}] ${warning.message}`, warning.details ?? {});
}
}
function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void {
if (session.subTimelineWaitOutcome === "ready" || !session.subTimelineWaitOutcome) return;
const scriptFailure = session.subTimelineWaitOutcome === "script_failure";
recordCaptureWarnings(session, [
{
code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
message: scriptFailure
? "A sub-composition timeline script failed to load"
: `Sub-composition timelines did not become ready within ${timeoutMs}ms`,
details: { timeoutMs },
},
]);
}
// Force every successfully-loaded `<img>` to be GPU-uploaded before the first
// frame capture. `naturalWidth > 0` means the bitmap has been decoded into
// CPU memory, but compositor-side GPU upload can still happen lazily on first
@@ -1553,6 +1656,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
() => session.scriptLoadFailures,
);
logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
recordSubTimelineWarning(session, pageReadyTimeout);
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
logInitPhase("applyVideoMetadataHints complete");
@@ -1602,6 +1706,10 @@ export async function initializeSession(session: CaptureSession): Promise<void>
`Continuing render — affected videos will appear as blank/black frames.`,
);
}
recordCaptureWarnings(
session,
await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout),
);
await recordSessionInitTelemetry(session, initStart);
@@ -1693,6 +1801,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
() => session.scriptLoadFailures,
);
logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
recordSubTimelineWarning(session, pageReadyTimeout);
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
logInitPhase("applyVideoMetadataHints complete");
@@ -1742,6 +1851,10 @@ export async function initializeSession(session: CaptureSession): Promise<void>
`Continuing render — affected videos will appear as blank/black frames.`,
);
}
recordCaptureWarnings(
session,
await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout),
);
await recordSessionInitTelemetry(session, initStart);
@@ -3232,6 +3345,15 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
p50TotalMs: medianOf(session.capturePerf.frameMs),
subTimelineWaitOutcome: session.subTimelineWaitOutcome,
warnings: session.warnings.map((warning) => ({
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
})),
staticDedupReused: session.staticDedupCount ?? 0,
staticDedupEnabled: session.staticDedupEnabled ?? false,
// armed ⟺ a non-empty static set survived verification; predicted === its size.
+20
View File
@@ -13,6 +13,24 @@ import type { Fps } from "@hyperframes/core";
*/
export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
export type CaptureWarningCode =
| "media_readiness_timeout"
| "media_load_failed"
| "audio_processing_failed"
| "sub_timeline_readiness_timeout"
| "sub_timeline_script_failure";
/** Structured correctness warning produced while preparing a capture session. */
export interface CaptureWarning {
code: CaptureWarningCode;
message: string;
details?: {
mediaType?: "image" | "video" | "audio";
sources?: string[];
timeoutMs?: number;
};
}
// ── Seek Protocol ──────────────────────────────────────────────────────────────
/**
@@ -202,6 +220,8 @@ export interface CapturePerfSummary {
p50TotalMs: number;
/** Sub-composition timeline wait outcome (absent pre-init). */
subTimelineWaitOutcome?: SubTimelineWaitOutcome;
/** Correctness warnings observed before or during capture. */
warnings?: CaptureWarning[];
/**
* Frames served from the static-dedup cache instead of a real seek+screenshot
* (opt-out HF_STATIC_DEDUP=false). 0 when dedup was off or never armed. NOT counted
+5
View File
@@ -11,10 +11,15 @@ export {
createRenderJob,
executeRenderJob,
RenderCancelledError,
RenderQualityError,
applyRenderWarningPolicy,
type RenderConfig,
type RenderConfigInput,
type RenderJob,
type RenderStatus,
type RenderOutcome,
type RenderStrictness,
type RenderWarning,
type RenderPerfSummary,
type ProgressCallback,
} from "./services/renderOrchestrator.js";
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -38,7 +38,29 @@ describe("parseRenderOptions — outputResolution", () => {
});
});
describe("parseRenderOptions — render strictness", () => {
it("preserves best-effort compatibility and requires an explicit strict opt-in", () => {
expect(parseRenderOptions({}).strictness).toBe("best-effort");
expect(parseRenderOptions({ bestEffort: true }).strictness).toBe("best-effort");
expect(parseRenderOptions({ bestEffort: false }).strictness).toBe("strict");
});
});
describe("prepareRenderBody — validation", () => {
it.each(["", " "])(
"treats an empty projectDir as absent and uses inline HTML",
async (projectDir) => {
const result = await prepareRenderBody({
projectDir,
html: "<html><body>inline</body></html>",
});
expect(result).toHaveProperty("prepared");
if (!("prepared" in result)) return;
expect(result.prepared.input.projectDir).not.toBe(process.cwd());
rmSync(result.prepared.cleanupProjectDir!, { recursive: true, force: true });
},
);
it("rejects an explicitly-supplied non-object variables", async () => {
const result = await prepareRenderBody({ variables: [1, 2], html: "<html></html>" });
expect(result).toHaveProperty("error");
+235 -147
View File
@@ -33,7 +33,9 @@ import {
RenderCancelledError,
createRenderJob,
executeRenderJob,
type ProgressCallback,
type RenderConfig,
type RenderJob,
} from "./services/renderOrchestrator.js";
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
import { startHealthWorker, type HealthWorkerHandle } from "./services/healthWorker.js";
@@ -79,6 +81,7 @@ interface RenderInput {
workers?: number;
useGpu: boolean;
debug: boolean;
strictness: RenderConfig["strictness"];
entryFile?: string;
/**
* data-composition-variables overrides forwarded into the render config.
@@ -100,36 +103,50 @@ interface PreparedRenderInput {
cleanupProjectDir?: string;
}
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
function parseServerFps(value: unknown): RenderInput["fps"] {
if (typeof value !== "number" && typeof value !== "string") return DEFAULT_SERVER_FPS;
const parsed = parseFps(value);
return parsed.ok ? parsed.value : DEFAULT_SERVER_FPS;
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseOutputCandidate(body: Record<string, unknown>): string | null {
return nonEmptyString(body.outputPath) ?? nonEmptyString(body.output) ?? null;
}
function parseServerQuality(value: unknown): RenderInput["quality"] {
return value === "draft" || value === "standard" || value === "high" ? value : "high";
}
function parseServerFormat(value: unknown): RenderInput["format"] {
return value === "mp4" || value === "webm" || value === "mov" ? value : undefined;
}
export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
// forgiving behaviour the original whitelist had — the producer surfaces a
// clearer downstream error if the value is genuinely unusable.
const fpsRaw = body.fps;
const fpsParse =
typeof fpsRaw === "number" || typeof fpsRaw === "string" ? parseFps(fpsRaw) : null;
const fps = fpsParse && fpsParse.ok ? fpsParse.value : ({ num: 30, den: 1 } as const);
const quality = (
["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high"
) as "draft" | "standard" | "high";
const fps = parseServerFps(body.fps);
const quality = parseServerQuality(body.quality);
const workers = typeof body.workers === "number" ? body.workers : undefined;
const useGpu = body.gpu === true;
const debug = body.debug === true;
const outputPath =
typeof body.outputPath === "string" && body.outputPath.trim().length > 0
? body.outputPath
: typeof body.output === "string" && body.output.trim().length > 0
? body.output
: null;
const entryFile =
typeof body.entryFile === "string" && body.entryFile.trim().length > 0
? body.entryFile.trim()
: undefined;
const format = (
["mp4", "webm", "mov"].includes(body.format as string) ? body.format : undefined
) as RenderInput["format"];
// Preserve the pre-structured-warning HTTP contract for callers that do
// not yet send this field. Strict readiness is an explicit opt-in via
// `bestEffort: false`; omission must keep producing degraded output with
// structured warnings while downstream callers migrate.
const strictness = body.bestEffort === false ? "strict" : "best-effort";
const outputPath = parseOutputCandidate(body);
const entryFile = nonEmptyString(body.entryFile);
const format = parseServerFormat(body.format);
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
? body.videoFrameFormat
: undefined;
@@ -143,6 +160,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
workers,
useGpu,
debug,
strictness,
entryFile,
format,
variables,
@@ -188,6 +206,7 @@ function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) {
workers: input.workers,
useGpu: input.useGpu,
debug: input.debug,
strictness: input.strictness,
entryFile: input.entryFile,
variables: input.variables,
outputResolution: input.outputResolution,
@@ -249,61 +268,77 @@ function validateOutputResolutionOverride(body: Record<string, unknown>): string
return undefined;
}
type PrepareRenderResult = { prepared: PreparedRenderInput } | { error: string };
function prepareProjectDirectory(
projectDir: unknown,
options: Omit<RenderInput, "projectDir">,
): PrepareRenderResult | null {
const candidate = nonEmptyString(projectDir);
if (!candidate) return null;
const absProjectDir = resolve(candidate);
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
return { error: `Project directory not found: ${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 } } };
}
async function resolveInlineRenderHtml(body: Record<string, unknown>): Promise<
| { html: string }
| {
error: string;
}
> {
const inlineHtml = typeof body.html === "string" ? body.html : "";
if (inlineHtml) return { html: inlineHtml };
const previewUrl = nonEmptyString(body.previewUrl);
if (!previewUrl)
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
try {
const response = await fetch(previewUrl, { method: "GET" });
if (!response.ok) {
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
}
return { html: await response.text() };
} catch (error) {
return {
error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function materializeInlineProject(
html: string,
options: Omit<RenderInput, "projectDir">,
): PrepareRenderResult {
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
writeFileSync(join(tempProjectDir, "index.html"), html, "utf-8");
return {
prepared: {
input: { projectDir: tempProjectDir, ...options },
cleanupProjectDir: tempProjectDir,
},
};
}
export async function prepareRenderBody(
body: Record<string, unknown>,
): Promise<{ prepared: PreparedRenderInput } | { error: string }> {
): Promise<PrepareRenderResult> {
// Reject explicitly-supplied-but-malformed overrides up front so the caller
// gets a clear 400 instead of a silently-ignored value.
const overrideError = validateRenderOverrides(body);
if (overrideError) return { error: overrideError };
const options = parseRenderOptions(body);
const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined;
if (projectDir) {
const absProjectDir = resolve(projectDir);
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
return { error: `Project directory not found: ${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 } } };
}
const previewUrl = typeof body.previewUrl === "string" ? body.previewUrl.trim() : "";
const inlineHtml = typeof body.html === "string" ? body.html : "";
if (!previewUrl && !inlineHtml) {
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
}
let htmlContent = inlineHtml;
if (!htmlContent) {
try {
const response = await fetch(previewUrl, { method: "GET" });
if (!response.ok) {
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
}
htmlContent = await response.text();
} catch (error) {
return {
error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
writeFileSync(join(tempProjectDir, "index.html"), htmlContent, "utf-8");
return {
prepared: {
input: {
projectDir: tempProjectDir,
...options,
},
cleanupProjectDir: tempProjectDir,
},
};
const project = prepareProjectDirectory(body.projectDir, options);
if (project) return project;
const source = await resolveInlineRenderHtml(body);
return "error" in source ? source : materializeInlineProject(source.html, options);
}
function resolveOutputPath(
@@ -372,6 +407,115 @@ function cleanupTempDir(dir: string | undefined, log: ProducerLogger): void {
}
}
function outputFileSize(path: string): number {
return existsSync(path) ? statSync(path).size : 0;
}
function createBlockingProgressReporter(log: ProducerLogger, requestId: string): ProgressCallback {
let lastLoggedPct = -10;
return (job, message) => {
const pct = job.progress;
if (pct < lastLoggedPct + 10) return;
lastLoggedPct = pct;
log.info(`render progress ${pct}%`, { requestId, stage: job.currentStage, message });
};
}
interface SseWriter {
writeSSE(event: { data: string }): Promise<void>;
}
async function prepareSseRenderRequest(
c: Context,
stream: SseWriter,
requestId: string,
): Promise<PreparedRenderInput | null> {
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: "Invalid JSON body",
stage: "validation",
}),
});
return null;
}
const prepared = await prepareRenderBody(body);
if (!("error" in prepared)) return prepared.prepared;
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: prepared.error,
stage: "validation",
}),
});
return null;
}
function createSseProgressReporter(stream: SseWriter, requestId: string): ProgressCallback {
return async (job, message) => {
await stream.writeSSE({
data: JSON.stringify({
type: "progress",
requestId,
stage: job.currentStage,
progress: job.progress,
framesRendered: job.framesRendered ?? 0,
totalFrames: job.totalFrames ?? 0,
message,
}),
});
};
}
async function writeRenderStreamFailure(input: {
error: unknown;
job: RenderJob;
stream: SseWriter;
requestId: string;
startedAtMs: number;
log: ProducerLogger;
}): Promise<void> {
const { error, job, stream, requestId, startedAtMs, log } = input;
if (error instanceof RenderCancelledError) {
await stream.writeSSE({
data: JSON.stringify({
type: "cancelled",
requestId,
stage: job.currentStage,
outcome: job.outcome ?? "cancelled",
message: error.message,
}),
});
return;
}
const errorMsg = error instanceof Error ? error.message : String(error);
const elapsedMs = Date.now() - startedAtMs;
log.error("render-stream failed", {
requestId,
elapsedMs,
error: errorMsg,
stage: job.currentStage,
});
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: errorMsg,
stage: job.currentStage,
elapsedMs,
errorDetails: job.errorDetails ?? null,
outcome: job.outcome ?? "failed",
warnings: job.warnings,
}),
});
}
// ---------------------------------------------------------------------------
// Handler factory
// ---------------------------------------------------------------------------
@@ -476,17 +620,15 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
const job = createRenderJob(buildRenderJobConfig(input, log));
let lastLoggedPct = -10;
try {
await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => {
const pct = Math.floor(j.progress * 100);
if (pct >= lastLoggedPct + 10) {
lastLoggedPct = pct;
log.info(`render progress ${pct}%`, { requestId, stage: j.currentStage, message });
}
});
await executeRenderJob(
job,
input.projectDir,
absoluteOutputPath,
createBlockingProgressReporter(log, requestId),
);
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
const fileSize = outputFileSize(absoluteOutputPath);
const durationMs = Date.now() - t0;
const outputToken = store.register(absoluteOutputPath);
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
@@ -506,6 +648,8 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
fileSize,
durationMs,
videoDurationSeconds: job.duration ?? null,
outcome: job.outcome ?? "completed",
warnings: job.warnings,
perf: job.perfSummary ?? null,
});
} catch (error) {
@@ -539,36 +683,11 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
const requestId = getRequestId(c);
const t0 = Date.now();
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: "Invalid JSON body",
stage: "validation",
}),
});
return;
}
const preparedResult = await prepareRenderBody(body);
if ("error" in preparedResult) {
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: preparedResult.error,
stage: "validation",
}),
});
return;
}
const prepared = await prepareSseRenderRequest(c, stream, requestId);
if (!prepared) return;
const { input, cleanupProjectDir, absoluteOutputPath } = resolvePreparedRenderOutput(
preparedResult.prepared,
prepared,
rendersDir,
log,
);
@@ -597,23 +716,11 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
job,
input.projectDir,
absoluteOutputPath,
async (j, message) => {
await stream.writeSSE({
data: JSON.stringify({
type: "progress",
requestId,
stage: j.currentStage,
progress: j.progress,
framesRendered: j.framesRendered ?? 0,
totalFrames: j.totalFrames ?? 0,
message,
}),
});
},
createSseProgressReporter(stream, requestId),
abortController.signal,
);
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
const fileSize = outputFileSize(absoluteOutputPath);
const outputToken = store.register(absoluteOutputPath);
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null });
@@ -626,38 +733,19 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
outputUrl,
fileSize,
videoDurationSeconds: job.duration ?? null,
outcome: job.outcome ?? "completed",
warnings: job.warnings,
perf: job.perfSummary ?? null,
}),
});
} catch (error) {
if (error instanceof RenderCancelledError) {
await stream.writeSSE({
data: JSON.stringify({
type: "cancelled",
requestId,
stage: job.currentStage,
message: error.message,
}),
});
return;
}
const errorMsg = error instanceof Error ? error.message : String(error);
const elapsedMs = Date.now() - t0;
log.error("render-stream failed", {
await writeRenderStreamFailure({
error,
job,
stream,
requestId,
elapsedMs,
error: errorMsg,
stage: job.currentStage,
});
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: errorMsg,
stage: job.currentStage,
elapsedMs,
errorDetails: job.errorDetails ?? null,
}),
startedAtMs: t0,
log,
});
} finally {
release();
@@ -19,7 +19,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
import { RenderQualityError } from "../renderOrchestrator.js";
import {
applyDistributedAudioWarningPolicy,
buildChunkSlices,
DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_PARALLEL_CHUNKS,
@@ -27,6 +29,7 @@ import {
plan,
resolveChunkPlan,
} from "./plan.js";
import { buildSyntheticRenderJob } from "./shared.js";
// Composition the tests render. `data-duration="1"` keeps the probe stage's
// `needsBrowser` gate `false` so plan() completes without launching Chrome.
@@ -54,6 +57,30 @@ afterAll(() => {
rmSync(runRoot, { recursive: true, force: true });
});
describe("distributed warning policy", () => {
const createJob = (strictness: "strict" | "best-effort") =>
buildSyntheticRenderJob({
fps: { num: 30, den: 1 },
format: "mp4",
quality: "high",
hdrMode: "force-sdr",
strictness,
entryFile: "index.html",
});
it("rejects distributed audio degradation in strict mode", () => {
const job = createJob("strict");
expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).toThrow(RenderQualityError);
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
});
it("records distributed audio degradation in best-effort mode", () => {
const job = createJob("best-effort");
expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).not.toThrow();
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
});
});
describe("resolveChunkPlan", () => {
it("returns 1 chunk when totalFrames fits in configChunkSize", () => {
const result = resolveChunkPlan(60, 240, 16);
@@ -44,6 +44,11 @@ import {
resolveConfig,
} from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import {
applyRenderWarningPolicy,
type RenderJob,
type RenderStrictness,
} from "../renderOrchestrator.js";
import { closeFileServerSafely } from "../fileServer.js";
import { runAudioStage } from "../render/stages/audioStage.js";
import { runCompileStage } from "../render/stages/compileStage.js";
@@ -199,6 +204,8 @@ export interface DistributedRenderConfig {
producerConfig?: EngineConfig;
/** Entry HTML file relative to `projectDir`. Defaults to `"index.html"`. */
entryFile?: string;
/** Strict rejects correctness warnings; best-effort returns a qualified outcome. */
strictness?: RenderStrictness;
/** Caller-supplied AbortSignal. Threaded through compile / probe / extract / audio stages. */
abortSignal?: AbortSignal;
/**
@@ -250,6 +257,25 @@ export interface PlanResult {
producerVersion: string;
}
/** Applies the same audio correctness policy used by the in-process renderer. */
export function applyDistributedAudioWarningPolicy(
job: RenderJob,
audioError: string,
log: ProducerLogger = defaultLogger,
): void {
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioError}`,
details: { mediaType: "audio" },
},
],
log,
);
}
/**
* Top-level directory names skipped by the `projectDir → planDir/compiled/`
* pre-seed copy. Real projects often contain `node_modules/`, VCS metadata,
@@ -739,6 +765,7 @@ export async function plan(
// HDR is banned in distributed mode. force-sdr keeps the
// extract / encoder paths off the HDR branches entirely.
hdrMode: config.hdrMode ?? "force-sdr",
strictness: config.strictness,
entryFile: config.entryFile ?? "index.html",
logger: config.logger,
producerConfig: config.producerConfig,
@@ -906,7 +933,7 @@ export async function plan(
assertNotAborted,
});
if (audioResult.audioError) {
log.warn(`[Render] Audio mix failed — output will be video-only: ${audioResult.audioError}`);
applyDistributedAudioWarningPolicy(job, audioResult.audioError, log);
}
// Promote staged artifacts from the temp work tree into the final planDir
@@ -100,6 +100,7 @@ export interface SyntheticRenderJobInput {
videoFrameFormat?: VideoFrameFormat;
outputResolution?: RenderConfig["outputResolution"];
hdrMode: RenderConfig["hdrMode"];
strictness?: RenderConfig["strictness"];
entryFile: string;
logger?: ProducerLogger;
producerConfig?: RenderConfig["producerConfig"];
@@ -126,6 +127,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
entryFile: input.entryFile,
logger: input.logger ?? defaultLogger,
hdrMode: input.hdrMode,
strictness: input.strictness,
producerConfig: input.producerConfig,
};
return createRenderJob(renderConfig);
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import {
RenderQualityError,
applyRenderWarningPolicy,
createRenderJob,
} from "../renderOrchestrator.js";
import { updateJobStatus } from "./shared.js";
import { OrderedRenderEventPublisher, publishRenderFailure } from "./renderEventPublisher.js";
describe("OrderedRenderEventPublisher", () => {
it("delivers immutable snapshots in order and waits for async sinks", async () => {
const delivered: Array<{ progress: number; message: string }> = [];
const publisher = new OrderedRenderEventPublisher(
async (job, message) => {
await Promise.resolve();
delivered.push({ progress: job.progress, message });
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
updateJobStatus(job, "preprocessing", "first", 5, publish);
updateJobStatus(job, "rendering", "second", 25, publish);
updateJobStatus(job, "complete", "done", 100, publish);
await publisher.flush();
expect(delivered).toEqual([
{ progress: 5, message: "first" },
{ progress: 25, message: "second" },
{ progress: 100, message: "done" },
]);
});
it("contains sink rejection and still delivers the terminal event", async () => {
const delivered: number[] = [];
const warn = vi.fn();
const publisher = new OrderedRenderEventPublisher(
async (job) => {
if (job.progress === 5) throw new Error("sink down");
delivered.push(job.progress);
},
{ error: vi.fn(), warn, info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
updateJobStatus(job, "preprocessing", "first", 5, publish);
updateJobStatus(job, "complete", "done", 100, publish);
await publisher.flush();
expect(delivered).toEqual([100]);
expect(warn).toHaveBeenCalledTimes(1);
});
it("publishes the complete structured failure in the terminal snapshot", async () => {
const delivered: Array<ReturnType<typeof createRenderJob>> = [];
const publisher = new OrderedRenderEventPublisher(
async (job) => {
delivered.push(job);
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
job.currentStage = "Capturing frames";
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
const errorDetails = {
message: "capture failed",
elapsedMs: 125,
freeMemoryMB: 512,
};
publishRenderFailure(
job,
{
error: "capture failed",
failedStage: job.currentStage,
errorDetails,
},
publish,
);
await publisher.flush();
expect(delivered).toHaveLength(1);
expect(delivered[0]).toMatchObject({
status: "failed",
currentStage: "Failed: capture failed",
outcome: "failed",
error: "capture failed",
failedStage: "Capturing frames",
errorDetails,
});
});
});
describe("updateJobStatus", () => {
it("keeps one bounded monotonic integer-percent representation", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
updateJobStatus(job, "rendering", "advance", 25.6);
updateJobStatus(job, "rendering", "stale", 20);
updateJobStatus(job, "rendering", "overflow", 120);
expect(job.progress).toBe(100);
});
it("timestamps every terminal outcome, including cancellation", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
updateJobStatus(job, "cancelled", "cancelled", 42);
expect(job.completedAt).toBeInstanceOf(Date);
expect(job.outcome).toBe("cancelled");
});
it("qualifies best-effort completion when correctness warnings exist", () => {
const job = createRenderJob({ fps: 30, quality: "high", strictness: "best-effort" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
applyRenderWarningPolicy(
job,
[
{
code: "media_load_failed",
message: "video failed",
details: { mediaType: "video", sources: ["missing.mp4"] },
},
],
log,
);
updateJobStatus(job, "complete", "done", 100);
expect(job.outcome).toBe("completed_with_warnings");
expect(job.warnings).toHaveLength(1);
});
it("preserves best-effort behavior when strictness is omitted", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "audio mix failed",
details: { mediaType: "audio", sources: ["broken.mp3"] },
},
],
log,
),
).not.toThrow();
expect(job.config.strictness).toBe("best-effort");
expect(job.warnings).toHaveLength(1);
});
it("fails explicitly strict renders on correctness warnings", () => {
const job = createRenderJob({ fps: 30, quality: "high", strictness: "strict" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "audio mix failed",
details: { mediaType: "audio", sources: ["broken.mp3"] },
},
],
log,
),
).toThrow(RenderQualityError);
});
});
@@ -0,0 +1,69 @@
import type { ProducerLogger } from "../../logger.js";
import type { ProgressCallback, RenderJob } from "../renderOrchestrator.js";
import { updateJobStatus } from "./shared.js";
function snapshotJob(job: RenderJob): RenderJob {
return {
...job,
warnings: job.warnings.map((warning) => ({
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
})),
};
}
/** Serializes progress delivery and contains sink failures at the boundary. */
export class OrderedRenderEventPublisher {
private tail: Promise<void> = Promise.resolve();
constructor(
private readonly sink: ProgressCallback | undefined,
private readonly log: ProducerLogger,
) {}
publish(job: RenderJob, message: string): void {
if (!this.sink) return;
const snapshot = snapshotJob(job);
this.tail = this.tail
.then(() => this.sink?.(snapshot, message))
.then(() => undefined)
.catch((error: unknown) => {
try {
this.log.warn("Render event sink rejected an update", {
status: snapshot.status,
progress: snapshot.progress,
error: error instanceof Error ? error.message : String(error),
});
} catch {
// A broken logger must not reopen the contained sink failure.
}
});
}
async flush(): Promise<void> {
await this.tail;
}
}
type StructuredRenderFailure = {
error: string;
failedStage: string;
errorDetails: NonNullable<RenderJob["errorDetails"]>;
};
/** Populates the complete failure contract before publishing its immutable terminal snapshot. */
export function publishRenderFailure(
job: RenderJob,
failure: StructuredRenderFailure,
onProgress?: ProgressCallback,
): void {
job.error = failure.error;
job.failedStage = failure.failedStage;
job.errorDetails = failure.errorDetails;
updateJobStatus(job, "failed", `Failed: ${failure.error}`, job.progress, onProgress);
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createRenderFileLogger } from "../renderOrchestrator.js";
describe("createRenderFileLogger", () => {
it("keeps concurrent debug logs scoped without replacing global console methods", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-render-log-"));
const firstPath = join(dir, "first.log");
const secondPath = join(dir, "second.log");
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
};
const base = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
const first = createRenderFileLogger(firstPath, base);
const second = createRenderFileLogger(secondPath, base);
await Promise.all([
Promise.resolve().then(() => first.info("first-only", { renderId: "a" })),
Promise.resolve().then(() => second.warn("second-only", { renderId: "b" })),
]);
expect(console.log).toBe(originalConsole.log);
expect(console.warn).toBe(originalConsole.warn);
expect(console.error).toBe(originalConsole.error);
expect(readFileSync(firstPath, "utf8")).toContain("first-only");
expect(readFileSync(firstPath, "utf8")).not.toContain("second-only");
expect(readFileSync(secondPath, "utf8")).toContain("second-only");
expect(readFileSync(secondPath, "utf8")).not.toContain("first-only");
});
});
@@ -233,11 +233,23 @@ export function updateJobStatus(
progress: number,
onProgress?: ProgressCallback,
): void {
job.warnings ??= [];
job.status = status;
job.currentStage = stage;
job.progress = progress;
if (status === "failed" || status === "complete") job.completedAt = new Date();
if (onProgress) onProgress(job, stage);
const boundedProgress = Math.max(0, Math.min(100, Math.round(progress)));
job.progress = Math.max(job.progress, boundedProgress);
if (status === "failed" || status === "complete" || status === "cancelled") {
job.completedAt = new Date();
job.outcome =
status === "failed"
? "failed"
: status === "cancelled"
? "cancelled"
: job.warnings.length > 0
? "completed_with_warnings"
: "completed";
}
if (onProgress) void onProgress(job, stage);
}
/**
@@ -40,6 +40,7 @@ import { join } from "node:path";
import {
type BeforeCaptureHook,
type CaptureOptions,
type CaptureWarning,
type EngineConfig,
type HdrTransfer,
type StreamingEncoder,
@@ -138,6 +139,19 @@ export interface CaptureHdrStageResult {
captureDurationMs: number;
/** ffmpeg-reported encode duration; overlapped with capture. */
encodeMs: number;
warnings: CaptureWarning[];
}
function cloneCaptureWarnings(warnings: readonly CaptureWarning[]): CaptureWarning[] {
return warnings.map((warning) => ({
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
}));
}
export async function runCaptureHdrStage(
@@ -490,5 +504,6 @@ export async function runCaptureHdrStage(
hdrPerf,
captureDurationMs,
encodeMs,
warnings: cloneCaptureWarnings(domSession.warnings),
};
}
@@ -67,6 +67,7 @@ import {
LOW_MEMORY_TOTAL_MB_THRESHOLD,
assertConfiguredFfmpegBinariesExist,
type CapturePerfSummary,
type CaptureWarning,
type SubTimelineWaitOutcome,
resolveBrowserGpuMode,
resolveHeadlessShellPath,
@@ -89,6 +90,10 @@ import {
import { defaultLogger, type ProducerLogger } from "../logger.js";
import { createMemorySampler, type MemorySampler, updateJobStatus } from "./render/shared.js";
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js";
import {
OrderedRenderEventPublisher,
publishRenderFailure,
} from "./render/renderEventPublisher.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { formatCaptureFrameName } from "../utils/paths.js";
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
@@ -195,6 +200,13 @@ export type RenderStatus =
| "failed"
| "cancelled";
export type RenderOutcome = "completed" | "completed_with_warnings" | "failed" | "cancelled";
export type RenderStrictness = "strict" | "best-effort";
export interface RenderWarning extends CaptureWarning {
stage: "capture-readiness";
}
export interface RenderConfig {
/**
* Frame rate as an exact rational. Integer fps is `{ num: 30, den: 1 }`;
@@ -252,6 +264,8 @@ export interface RenderConfig {
workers?: number;
useGpu?: boolean;
debug?: boolean;
/** Strict rejects correctness warnings; best-effort returns a qualified outcome. */
strictness?: RenderStrictness;
/** Entry HTML file relative to projectDir. Defaults to "index.html". */
entryFile?: string;
/** Full producer config. When provided, env vars are not read. */
@@ -476,6 +490,8 @@ export interface RenderJob {
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
outcome?: RenderOutcome;
warnings: RenderWarning[];
error?: string;
outputPath?: string;
duration?: number;
@@ -497,7 +513,53 @@ export interface RenderJob {
};
}
export type ProgressCallback = (job: RenderJob, message: string) => void;
export type ProgressCallback = (job: RenderJob, message: string) => void | Promise<void>;
export class RenderQualityError extends Error {
constructor(readonly warnings: readonly RenderWarning[]) {
super(
`Render blocked by ${warnings.length} correctness warning${warnings.length === 1 ? "" : "s"}: ` +
warnings.map((warning) => warning.code).join(", "),
);
this.name = "RenderQualityError";
}
}
export function applyRenderWarningPolicy(
job: RenderJob,
captureWarnings: readonly CaptureWarning[],
log: ProducerLogger = defaultLogger,
): void {
job.warnings ??= [];
const existing = new Set(
job.warnings.map((warning) => `${warning.code}:${JSON.stringify(warning.details ?? {})}`),
);
for (const warning of captureWarnings) {
const key = `${warning.code}:${JSON.stringify(warning.details ?? {})}`;
if (existing.has(key)) continue;
existing.add(key);
job.warnings.push({
...warning,
stage: "capture-readiness",
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
});
}
if (job.warnings.length === 0) return;
const strictness = job.config.strictness ?? "best-effort";
log.warn("Render completed capture with correctness warnings", {
strictness,
warningCodes: job.warnings.map((warning) => warning.code),
});
if (strictness === "strict") {
throw new RenderQualityError(job.warnings);
}
}
export class RenderCancelledError extends Error {
reason: "user_cancelled" | "timeout" | "aborted";
@@ -511,41 +573,34 @@ export class RenderCancelledError extends Error {
}
}
function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger): () => void {
const origLog = console.log;
const origError = console.error;
const origWarn = console.warn;
export function createRenderFileLogger(
logPath: string,
base: ProducerLogger = defaultLogger,
): ProducerLogger {
const write = (prefix: string, args: unknown[]) => {
const ts = new Date().toISOString();
const line = `[${ts}] ${prefix} ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
try {
appendFileSync(logPath, line);
} catch (err) {
log.debug("Debug log write failed", {
base.debug("Debug log write failed", {
logPath,
error: err instanceof Error ? err.message : String(err),
});
}
};
console.log = (...args: unknown[]) => {
write("LOG", args);
origLog(...args);
const wrap = (level: "error" | "warn" | "info" | "debug", prefix: string) => {
return (message: string, meta?: Record<string, unknown>) => {
write(prefix, meta ? [message, meta] : [message]);
base[level](message, meta);
};
};
console.error = (...args: unknown[]) => {
write("ERR", args);
origError(...args);
};
console.warn = (...args: unknown[]) => {
write("WRN", args);
origWarn(...args);
};
return () => {
console.log = origLog;
console.error = origError;
console.warn = origWarn;
return {
error: wrap("error", "ERR"),
warn: wrap("warn", "WRN"),
info: wrap("info", "LOG"),
debug: wrap("debug", "DBG"),
isLevelEnabled: (level) => base.isLevelEnabled?.(level) ?? true,
};
}
@@ -958,11 +1013,16 @@ export type RenderConfigInput = Omit<RenderConfig, "fps"> & { fps: FpsInput };
export function createRenderJob(config: RenderConfigInput): RenderJob {
return {
id: randomUUID(),
config: { ...config, fps: toFps(config.fps) },
config: {
...config,
fps: toFps(config.fps),
strictness: config.strictness ?? "best-effort",
},
status: "queued",
progress: 0,
currentStage: "Queued",
createdAt: new Date(),
warnings: [],
};
}
@@ -1429,7 +1489,7 @@ export async function executeRenderJob(
job: RenderJob,
projectDir: string,
outputPath: string,
onProgress?: ProgressCallback,
progressSink?: ProgressCallback,
abortSignal?: AbortSignal,
): Promise<void> {
const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -1443,11 +1503,16 @@ export async function executeRenderJob(
? join(debugDir, job.id)
: mkdtempSync(join(outputDir, `work-${job.id}-`));
const pipelineStart = Date.now();
const log = job.config.logger ?? defaultLogger;
const baseLog = job.config.logger ?? defaultLogger;
const logPath = job.config.debug ? join(workDir, "render.log") : null;
const log = logPath ? createRenderFileLogger(logPath, baseLog) : baseLog;
const eventPublisher = new OrderedRenderEventPublisher(progressSink, log);
const onProgress: ProgressCallback | undefined = progressSink
? (progressJob, message) => eventPublisher.publish(progressJob, message)
: undefined;
let fileServer: FileServerHandle | null = null;
let probeSession: CaptureSession | null = null;
let lastBrowserConsole: string[] = [];
let restoreLogger: (() => void) | null = null;
// Composition dimensions captured for the error path (OOM guidance). Assigned
// once the composition metadata / frame count are resolved inside the try.
let captureCompositionWidth: number | undefined;
@@ -1506,6 +1571,7 @@ export async function executeRenderJob(
// the sub-timeline-wait outcome for a render that fails downstream of a
// fail-fast (aggregated into the success-path perf summary below too).
const dedupPerfs: CapturePerfSummary[] = [];
const layeredCaptureWarnings: CaptureWarning[] = [];
const recordTransientRetryObservability = (): void => {
const count = captureAttempts.filter((a) => a.reason === "transient-retry").length;
if (count > 0) updateCaptureObservability({ transientRetries: count });
@@ -1534,8 +1600,6 @@ export async function executeRenderJob(
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
if (job.config.debug) {
const logPath = join(workDir, "render.log");
restoreLogger = installDebugLogger(logPath, log);
log.info("[Render] Debug artifacts enabled", { workDir, logPath });
}
@@ -1867,7 +1931,17 @@ export async function executeRenderJob(
const { audioOutputPath, hasAudio } = audioResult;
perfStages.audioProcessMs = audioResult.audioProcessMs;
if (audioResult.audioError) {
log.warn(`[Render] Audio mix failed — output will be video-only: ${audioResult.audioError}`);
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`,
details: { mediaType: "audio" },
},
],
log,
);
}
// ── Stage 4: Frame capture ──────────────────────────────────────────
@@ -2519,6 +2593,7 @@ export async function executeRenderJob(
}),
);
lastBrowserConsole = hdrRes.lastBrowserConsole;
layeredCaptureWarnings.push(...hdrRes.warnings);
hdrPerf = hdrRes.hdrPerf;
perfStages.captureMs = hdrRes.captureDurationMs;
perfStages.captureFrameMs = hdrRes.captureDurationMs;
@@ -2829,6 +2904,12 @@ export async function executeRenderJob(
}
} // end SDR capture paths block
applyRenderWarningPolicy(
job,
[...layeredCaptureWarnings, ...dedupPerfs.flatMap((perf) => perf.warnings ?? [])],
log,
);
if (probeSession !== null) {
const remainingProbeSession: CaptureSession = probeSession;
lastBrowserConsole = remainingProbeSession.browserConsoleBuffer;
@@ -2871,6 +2952,7 @@ export async function executeRenderJob(
// ── Complete ─────────────────────────────────────────────────────────
job.outputPath = outputPath;
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
await eventPublisher.flush();
const totalElapsed = Date.now() - pipelineStart;
@@ -2954,12 +3036,11 @@ export async function executeRenderJob(
log,
);
}
if (restoreLogger) restoreLogger();
} catch (error) {
if (error instanceof RenderCancelledError || abortSignal?.aborted) {
job.error = error instanceof Error ? error.message : "render_cancelled";
updateJobStatus(job, "cancelled", "Render cancelled", job.progress, onProgress);
await eventPublisher.flush();
await cleanupRenderResources({
fileServer,
probeSession,
@@ -2968,7 +3049,6 @@ export async function executeRenderJob(
log,
label: "cancel",
});
if (restoreLogger) restoreLogger();
throw error instanceof RenderCancelledError
? error
: new RenderCancelledError("render_cancelled");
@@ -3015,16 +3095,14 @@ export async function executeRenderJob(
);
}
job.error = errorMessage;
updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
job.failedStage = job.currentStage;
const failedStage = job.currentStage || "pipeline";
const observabilitySummary = observability.summary({
lastBrowserConsole,
capture: captureObservability,
extraction: extractionObservability,
compositionHash,
});
job.errorDetails = buildRenderErrorDetails({
const errorDetails = buildRenderErrorDetails({
error,
pipelineStartMs: pipelineStart,
lastBrowserConsole,
@@ -3033,9 +3111,19 @@ export async function executeRenderJob(
observability: observabilitySummary,
subTimelineWait: worstSubTimelineWaitOutcome(dedupPerfs),
});
publishRenderFailure(
job,
{
error: errorMessage,
failedStage,
errorDetails,
},
onProgress,
);
await eventPublisher.flush();
log.info("[Render] Failure summary", {
failedStage: job.currentStage,
failedStage,
error: errorMessage,
elapsedMs: Date.now() - pipelineStart,
stageTimings: perfStages,
@@ -3068,7 +3156,6 @@ export async function executeRenderJob(
label: "error",
});
if (restoreLogger) restoreLogger();
throw error;
} finally {
memSampler?.stop();
@@ -4,7 +4,7 @@
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } iframe { border: 0; display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="iframe-test" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div id="root" data-composition-id="iframe-test" data-width="1920" data-height="1080" data-start="0" data-duration="5" data-no-timeline>
<iframe src="target.html" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></iframe>
</div>
<script>window.__timelines = window.__timelines || {};</script>
@@ -4,7 +4,7 @@
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } canvas { display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="raf-ball" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div id="root" data-composition-id="raf-ball" data-width="1920" data-height="1080" data-start="0" data-duration="5" data-no-timeline>
<canvas id="c" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></canvas>
</div>
<script>
@@ -47,6 +47,7 @@
data-duration="3"
data-width="480"
data-height="332"
data-no-timeline
>
<video
id="clip"