mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(render): surface structured outcomes (#2153)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user