Skip to content

Commit e78bc4e

Browse files
committed
fix(render): surface structured outcomes
1 parent b6a53a4 commit e78bc4e

22 files changed

Lines changed: 874 additions & 183 deletions

File tree

packages/cli/src/commands/render.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ vi.mock("../utils/producer.js", () => ({
7777
}),
7878
createRenderJob: vi.fn((config: Record<string, unknown>) => {
7979
producerState.createdJobs.push(config);
80-
return { config, progress: 100 };
80+
return { config, progress: 100, outcome: "completed", warnings: [] };
8181
}),
8282
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
8383
})),
@@ -410,6 +410,21 @@ describe("renderLocal browser GPU config", () => {
410410
expect(producerState.createdJobs[0]?.debug).toBe(true);
411411
});
412412

413+
it("defaults to strict readiness and forwards explicit best-effort mode", async () => {
414+
await renderLocal("/tmp/project", "/tmp/out.mp4", {
415+
fps: { num: 30, den: 1 },
416+
quality: "standard",
417+
format: "mp4",
418+
gpu: false,
419+
browserGpuMode: "software",
420+
hdrMode: "auto",
421+
quiet: true,
422+
bestEffort: true,
423+
});
424+
425+
expect(producerState.createdJobs[0]?.strictness).toBe("best-effort");
426+
});
427+
413428
it("omits variables from createRenderJob when not provided", async () => {
414429
await renderLocal("/tmp/project", "/tmp/out.mp4", {
415430
fps: { num: 30, den: 1 },

packages/cli/src/commands/render.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,12 @@ export default defineCommand({
266266
"Write full render diagnostics and keep intermediate artifacts under the producer .debug directory.",
267267
default: false,
268268
},
269+
"best-effort": {
270+
type: "boolean",
271+
description:
272+
"Allow output with structured capture-readiness warnings. By default missing or unready media fails the render.",
273+
default: false,
274+
},
269275
strict: {
270276
type: "boolean",
271277
description: "Fail render on lint errors",
@@ -619,6 +625,7 @@ export default defineCommand({
619625
const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg);
620626
const quiet = args.quiet ?? false;
621627
const debug = args.debug ?? false;
628+
const bestEffort = args["best-effort"] ?? false;
622629
const batchJson = args.json ?? false;
623630
const effectiveQuiet = quiet || (batchPath != null && batchJson);
624631
const strictAll = args["strict-all"] ?? false;
@@ -898,6 +905,7 @@ export default defineCommand({
898905
protocolTimeout,
899906
playerReadyTimeout,
900907
debug,
908+
bestEffort,
901909
exitAfterComplete: false,
902910
throwOnError: true,
903911
skipFeedback: true,
@@ -956,6 +964,7 @@ export default defineCommand({
956964
videoFrameFormat,
957965
quiet,
958966
debug,
967+
bestEffort,
959968
variables,
960969
entryFile,
961970
outputResolution,
@@ -984,6 +993,7 @@ export default defineCommand({
984993
quiet,
985994
browserPath,
986995
debug,
996+
bestEffort,
987997
variables,
988998
entryFile,
989999
outputResolution,
@@ -1002,6 +1012,8 @@ export default defineCommand({
10021012
export interface SingleRenderResult {
10031013
durationMs?: number;
10041014
renderTimeMs: number;
1015+
outcome?: "completed" | "completed_with_warnings";
1016+
warnings?: Array<{ code: string; message: string }>;
10051017
}
10061018

10071019
export function renderLintContinuationHint(strictErrors: boolean): string {
@@ -1032,6 +1044,7 @@ interface RenderOptions {
10321044
videoFrameFormat?: VideoFrameFormat;
10331045
quiet: boolean;
10341046
debug?: boolean;
1047+
bestEffort?: boolean;
10351048
browserPath?: string;
10361049
variables?: Record<string, unknown>;
10371050
entryFile?: string;
@@ -1366,6 +1379,7 @@ async function renderDocker(
13661379
outputResolution: options.outputResolution,
13671380
pageSideCompositing: options.pageSideCompositing,
13681381
debug: options.debug,
1382+
bestEffort: options.bestEffort,
13691383
experimentalFastCapture: options.experimentalFastCapture,
13701384
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
13711385
},
@@ -1486,6 +1500,7 @@ export async function renderLocal(
14861500
entryFile: options.entryFile,
14871501
outputResolution: options.outputResolution,
14881502
debug: options.debug,
1503+
strictness: options.bestEffort ? "best-effort" : "strict",
14891504
});
14901505

14911506
const onProgress = options.quiet
@@ -1511,6 +1526,11 @@ export async function renderLocal(
15111526

15121527
maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet);
15131528
const elapsed = Date.now() - startTime;
1529+
if (job.outcome === "completed_with_warnings") {
1530+
for (const warning of job.warnings) {
1531+
console.warn(c.warn(` [${warning.code}] ${warning.message}`));
1532+
}
1533+
}
15141534
trackRenderMetrics(job, elapsed, options, false);
15151535
printRenderComplete(
15161536
outputPath,
@@ -1530,7 +1550,14 @@ export async function renderLocal(
15301550
const durationMs = job.perfSummary
15311551
? Math.round(job.perfSummary.compositionDurationSeconds * 1000)
15321552
: undefined;
1533-
return { renderTimeMs: elapsed, durationMs };
1553+
const outcome =
1554+
job.outcome === "completed_with_warnings" ? "completed_with_warnings" : "completed";
1555+
return {
1556+
renderTimeMs: elapsed,
1557+
durationMs,
1558+
outcome,
1559+
warnings: job.warnings.map((warning) => ({ code: warning.code, message: warning.message })),
1560+
};
15341561
}
15351562

15361563
type UnrefableTimer = {

packages/cli/src/server/studioRenderTelemetry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ describe("studioRenderTelemetry", () => {
280280
progress: 25,
281281
currentStage: "Starting frame capture",
282282
createdAt: new Date(),
283+
warnings: [],
283284
errorDetails: {
284285
message: "Navigation timeout of 60000 ms exceeded",
285286
elapsedMs: 60_001,

packages/cli/src/utils/dockerRunArgs.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ describe("buildDockerRunArgs", () => {
172172
videoFrameFormat: "png",
173173
quiet: true,
174174
debug: true,
175+
bestEffort: true,
175176
entryFile: "compositions/intro.html",
176177
experimentalFastCapture: true,
177178
},
@@ -191,6 +192,7 @@ describe("buildDockerRunArgs", () => {
191192
expect(args).toContain("png");
192193
expect(args).toContain("--quiet");
193194
expect(args).toContain("--debug");
195+
expect(args).toContain("--best-effort");
194196
expect(args).toContain("--gpu");
195197
expect(args).toContain("--no-browser-gpu");
196198
expect(args).toContain("--hdr");

packages/cli/src/utils/dockerRunArgs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface DockerRenderOptions {
5151
videoFrameFormat?: "auto" | "jpg" | "png";
5252
quiet: boolean;
5353
debug?: boolean;
54+
bestEffort?: boolean;
5455
variables?: Record<string, unknown>;
5556
entryFile?: string;
5657
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
@@ -136,6 +137,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
136137
: []),
137138
...(options.quiet ? ["--quiet"] : []),
138139
...(options.debug ? ["--debug"] : []),
140+
...(options.bestEffort ? ["--best-effort"] : []),
139141
...(options.gpu ? ["--gpu"] : []),
140142
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
141143
...(options.hdrMode === "force-hdr" ? ["--hdr"] : []),

packages/engine/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export type {
4040
CaptureResult,
4141
CaptureBufferResult,
4242
CapturePerfSummary,
43+
CaptureWarning,
44+
CaptureWarningCode,
4345
SubTimelineWaitOutcome,
4446
} from "./types.js";
4547

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { Page } from "puppeteer-core";
3+
import { collectMediaReadinessWarnings } from "./frameCapture.js";
4+
5+
function makePage(input: {
6+
images?: Array<{ src: string; complete: boolean; naturalWidth: number }>;
7+
media?: Array<{
8+
id?: string;
9+
tagName: "VIDEO" | "AUDIO";
10+
src: string;
11+
readyState: number;
12+
networkState?: number;
13+
error?: unknown;
14+
}>;
15+
}): Page {
16+
return {
17+
evaluate: async (fn: (skipIds: readonly string[]) => unknown, skipIds: readonly string[]) => {
18+
const previousDocument = Reflect.get(globalThis, "document");
19+
const previousMedia = Reflect.get(globalThis, "HTMLMediaElement");
20+
Reflect.set(globalThis, "HTMLMediaElement", {
21+
NETWORK_NO_SOURCE: 3,
22+
HAVE_CURRENT_DATA: 2,
23+
});
24+
Reflect.set(globalThis, "document", {
25+
querySelectorAll: (selector: string) => {
26+
if (selector === "img") {
27+
return (input.images ?? []).map((image) => ({
28+
...image,
29+
getAttribute: (name: string) => (name === "src" ? image.src : null),
30+
}));
31+
}
32+
const media =
33+
selector === "video"
34+
? (input.media ?? []).filter((element) => element.tagName === "VIDEO")
35+
: (input.media ?? []);
36+
return media.map((media) => ({
37+
id: media.id ?? "",
38+
tagName: media.tagName,
39+
currentSrc: media.src,
40+
readyState: media.readyState,
41+
networkState: media.networkState ?? 1,
42+
error: media.error ?? null,
43+
getAttribute: (name: string) => (name === "src" ? media.src : null),
44+
}));
45+
},
46+
});
47+
try {
48+
return await fn(skipIds);
49+
} finally {
50+
Reflect.set(globalThis, "document", previousDocument);
51+
Reflect.set(globalThis, "HTMLMediaElement", previousMedia);
52+
}
53+
},
54+
} as unknown as Page;
55+
}
56+
57+
describe("collectMediaReadinessWarnings", () => {
58+
it("returns stable warnings for visual media and ignores out-of-band audio", async () => {
59+
const page = makePage({
60+
images: [
61+
{ src: "/pending.png", complete: false, naturalWidth: 0 },
62+
{ src: "/broken.png", complete: true, naturalWidth: 0 },
63+
],
64+
media: [
65+
{ tagName: "VIDEO", src: "/broken.mp4", readyState: 0, error: new Error("decode") },
66+
{ tagName: "AUDIO", src: "/pending.mp3", readyState: 1 },
67+
{ id: "injected", tagName: "VIDEO", src: "/injected.mp4", readyState: 0 },
68+
],
69+
});
70+
71+
const warnings = await collectMediaReadinessWarnings(page, ["injected"], 45000);
72+
73+
expect(warnings.map((warning) => [warning.code, warning.details?.mediaType])).toEqual([
74+
["media_readiness_timeout", "image"],
75+
["media_load_failed", "image"],
76+
["media_load_failed", "video"],
77+
]);
78+
expect(warnings.every((warning) => warning.details?.timeoutMs === 45000)).toBe(true);
79+
});
80+
81+
it("returns no warnings when every relevant resource is ready", async () => {
82+
const page = makePage({
83+
images: [{ src: "/ready.png", complete: true, naturalWidth: 100 }],
84+
media: [
85+
{ tagName: "VIDEO", src: "/ready.mp4", readyState: 2 },
86+
{ tagName: "AUDIO", src: "/ready.mp3", readyState: 2 },
87+
],
88+
});
89+
90+
await expect(collectMediaReadinessWarnings(page, [], 1000)).resolves.toEqual([]);
91+
});
92+
});

0 commit comments

Comments
 (0)