Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ vi.mock("../utils/producer.js", () => ({
producerState.resolveConfigCalls.push(overrides);
return { ...overrides, resolved: true };
}),
createRenderRequest: vi.fn(
(input: {
projectDir: string;
outputPath: string;
engineConfig: unknown;
options: object;
}) => ({
version: 1,
projectDir: input.projectDir,
outputPath: input.outputPath,
options: { ...input.options, engineConfig: input.engineConfig },
}),
),
renderConfigFromRequest: vi.fn(
(request: { options: Record<string, unknown> }, runtime: { logger?: unknown }) => {
const { engineConfig, ...options } = request.options;
return { ...options, producerConfig: engineConfig, logger: runtime.logger };
},
),
createRenderJob: vi.fn((config: Record<string, unknown>) => {
producerState.createdJobs.push(config);
return { config, progress: 100, outcome: "completed", warnings: [] };
Expand Down
61 changes: 34 additions & 27 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,8 @@ async function renderDocker(
bestEffort: options.bestEffort,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
protocolTimeoutMs: options.protocolTimeout,
playerReadyTimeoutMs: options.playerReadyTimeout,
},
});

Expand Down Expand Up @@ -1629,34 +1631,39 @@ export async function renderLocal(
producer.createConsoleLogger?.(options.debug ? "debug" : "info") ?? createNoopProducerLogger(),
);

const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
useGpu: options.gpu,
logger,
producerConfig: producer.resolveConfig({
browserGpuMode: options.browserGpuMode ?? "software",
...(options.pageNavigationTimeoutMs != null
? { pageNavigationTimeout: options.pageNavigationTimeoutMs }
: {}),
...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }),
...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }),
...(options.vp9CpuUsed != null ? { vp9CpuUsed: options.vp9CpuUsed } : {}),
}),
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
const engineConfig = producer.resolveConfig({
browserGpuMode: options.browserGpuMode ?? "software",
...(options.pageNavigationTimeoutMs != null
? { pageNavigationTimeout: options.pageNavigationTimeoutMs }
: {}),
...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }),
...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }),
...(options.vp9CpuUsed != null ? { vp9CpuUsed: options.vp9CpuUsed } : {}),
});
const request = producer.createRenderRequest({
projectDir,
outputPath,
engineConfig,
options: {
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
useGpu: options.gpu,
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
},
});
const job = producer.createRenderJob(producer.renderConfigFromRequest(request, { logger }));

const onProgress = options.quiet
? undefined
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,22 @@ describe("buildDockerRunArgs", () => {
expect(args).not.toContain("--browser-timeout");
});

it("forwards protocol and player-ready timeouts without unit conversion", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: {
...BASE,
protocolTimeoutMs: 240_000,
playerReadyTimeoutMs: 90_000,
},
});

expect(args).toContain("--protocol-timeout");
expect(args[args.indexOf("--protocol-timeout") + 1]).toBe("240000");
expect(args).toContain("--player-ready-timeout");
expect(args[args.indexOf("--player-ready-timeout") + 1]).toBe("90000");
});

it("forwards rational --fps verbatim (NTSC 30000/1001)", () => {
// Regression for the fps fraction-syntax feature: the rational form must
// survive the host → container hop as a single `30000/1001` argument so
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface DockerRenderOptions {
* `--browser-timeout` flag).
*/
pageNavigationTimeoutMs?: number;
/** CDP protocol timeout in milliseconds. */
protocolTimeoutMs?: number;
/** Player readiness timeout in milliseconds. */
playerReadyTimeoutMs?: number;
}

/**
Expand Down Expand Up @@ -154,5 +158,11 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.pageNavigationTimeoutMs != null
? ["--browser-timeout", String(options.pageNavigationTimeoutMs / 1000)]
: []),
...(options.protocolTimeoutMs != null
? ["--protocol-timeout", String(options.protocolTimeoutMs)]
: []),
...(options.playerReadyTimeoutMs != null
? ["--player-ready-timeout", String(options.playerReadyTimeoutMs)]
: []),
];
}
206 changes: 206 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,212 @@ export const DEFAULT_CONFIG: EngineConfig = {
debug: false,
};

const OPTIONAL_ENGINE_CONFIG_FIELDS = [
"chromePath",
"expectedChromiumMajor",
"pageSideCompositingAutoDisabled",
"forceScreenshotExplicitlyOptedOut",
"streamingEncodeAutoDisabledOnWin32Compound",
"runtimeManifestPath",
"extractCacheDir",
] as const;

const BOOLEAN_ENGINE_CONFIG_FIELDS = [
"disableGpu",
"enableBrowserPool",
"forceScreenshot",
"staticFrameDedup",
"useDrawElement",
"enableDrawElementWorkerEncode",
"lowMemoryMode",
"enablePageSideCompositing",
"enableChunkedEncode",
"enableStreamingEncode",
"hdrAutoDetect",
"verifyRuntime",
"debug",
] as const;

const POSITIVE_NUMBER_ENGINE_CONFIG_FIELDS = [
"browserTimeout",
"protocolTimeout",
"chunkSizeFrames",
"ffmpegEncodeTimeout",
"ffmpegProcessTimeout",
"ffmpegStreamingTimeout",
"frameDataUriCacheLimit",
"frameDataUriCacheBytesLimitMb",
"playerReadyTimeout",
"renderReadyTimeout",
"pageNavigationTimeout",
"extractCacheMaxBytes",
] as const;

const ENUM_ENGINE_CONFIG_FIELDS = {
fps: [24, 30, 60],
quality: ["draft", "standard", "high"],
format: ["jpeg", "png"],
browserGpuMode: ["software", "hardware", "auto"],
} as const;

function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value))
);
}

function assertEngineConfigNumber(
config: Record<string, unknown>,
field: string,
min: number,
integer = false,
): void {
const value = config[field];
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < min ||
(integer && !Number.isInteger(value))
) {
throw new Error(
`Engine config ${field} must be a ${integer ? "finite integer" : "finite number"} >= ${min}`,
);
}
}

function assertPositiveEngineConfigNumber(config: Record<string, unknown>, field: string): void {
const value = config[field];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`Engine config ${field} must be a finite number > 0`);
}
}

function assertEngineConfigEnum(
config: Record<string, unknown>,
field: string,
values: readonly unknown[],
): void {
if (!values.includes(config[field])) throw new Error(`Engine config ${field} is invalid`);
}

function validateRequiredEngineConfigFields(config: Record<string, unknown>): void {
const requiredFields = Object.keys(DEFAULT_CONFIG);
for (const field of requiredFields) {
if (!Object.hasOwn(config, field)) {
throw new Error(`Engine config snapshot is missing required field ${field}`);
}
}
const allowedFields = new Set([...requiredFields, ...OPTIONAL_ENGINE_CONFIG_FIELDS]);
for (const field of Object.keys(config)) {
if (!allowedFields.has(field))
throw new Error(`Engine config snapshot has unknown field ${field}`);
}
}

function validateEngineConfigScalars(config: Record<string, unknown>): void {
for (const [field, values] of Object.entries(ENUM_ENGINE_CONFIG_FIELDS)) {
assertEngineConfigEnum(config, field, values);
}
assertEngineConfigNumber(config, "jpegQuality", 0);
if (typeof config.jpegQuality === "number" && config.jpegQuality > 100) {
throw new Error("Engine config jpegQuality must be <= 100");
}
}

function validateEngineConfigParallelism(config: Record<string, unknown>): void {
if (
config.concurrency !== "auto" &&
(typeof config.concurrency !== "number" ||
!Number.isInteger(config.concurrency) ||
config.concurrency < 1)
) {
throw new Error("Engine config concurrency must be a positive integer or auto");
}
assertPositiveEngineConfigNumber(config, "coresPerWorker");
for (const field of ["minParallelFrames", "largeRenderThreshold"] as const) {
assertEngineConfigNumber(config, field, 0, true);
}
}

function validateEngineConfigVp9(config: Record<string, unknown>): void {
if (
typeof config.vp9CpuUsed !== "number" ||
!Number.isInteger(config.vp9CpuUsed) ||
config.vp9CpuUsed < -8 ||
config.vp9CpuUsed > 8
) {
throw new Error("Engine config vp9CpuUsed must be an integer in [-8, 8]");
}
}

function validateEngineConfigRuntime(config: Record<string, unknown>): void {
for (const field of BOOLEAN_ENGINE_CONFIG_FIELDS) {
if (typeof config[field] !== "boolean")
throw new Error(`Engine config ${field} must be a boolean`);
}
for (const field of POSITIVE_NUMBER_ENGINE_CONFIG_FIELDS) {
assertEngineConfigNumber(config, field, 1, field === "frameDataUriCacheLimit");
}
assertEngineConfigNumber(config, "streamingEncodeMaxDurationSeconds", 0);
assertEngineConfigNumber(config, "audioGain", 0);
}

function validateEngineConfigHdr(config: Record<string, unknown>): void {
const { hdr } = config;
if (
hdr !== false &&
(!isPlainObject(hdr) ||
Object.keys(hdr).length !== 1 ||
(hdr.transfer !== "hlg" && hdr.transfer !== "pq"))
) {
throw new Error("Engine config hdr must be false or an hlg/pq transfer object");
}
}

function validateOptionalEngineConfigFields(config: Record<string, unknown>): void {
for (const field of ["chromePath", "runtimeManifestPath", "extractCacheDir"] as const) {
if (
config[field] !== undefined &&
(typeof config[field] !== "string" || config[field].length === 0)
) {
throw new Error(`Engine config ${field} must be a non-empty string`);
}
}
if (config.expectedChromiumMajor !== undefined) {
assertEngineConfigNumber(config, "expectedChromiumMajor", 1, true);
}
for (const field of [
"pageSideCompositingAutoDisabled",
"forceScreenshotExplicitlyOptedOut",
"streamingEncodeAutoDisabledOnWin32Compound",
] as const) {
if (config[field] !== undefined && typeof config[field] !== "boolean") {
throw new Error(`Engine config ${field} must be a boolean`);
}
}
}

/**
* Validate a complete EngineConfig crossing a JSON wire boundary.
*
* `resolveConfig()` intentionally accepts partial programmatic overrides, but
* a serialized render request stores a resolved snapshot. Accepting a partial
* snapshot would skip the orchestrator's `resolveConfig()` fallback entirely.
*/
export function validateEngineConfigSnapshot(value: unknown): asserts value is EngineConfig {
if (!isPlainObject(value)) throw new Error("Engine config snapshot must be a plain object");
validateRequiredEngineConfigFields(value);
validateEngineConfigScalars(value);
validateEngineConfigParallelism(value);
validateEngineConfigVp9(value);
validateEngineConfigRuntime(value);
validateEngineConfigHdr(value);
validateOptionalEngineConfigFields(value);
}

/**
* Reference canvas area for the baseline `protocolTimeout`: 1080p. A single CDP
* call (`Runtime.callFunctionOn` seek+paint, or `Page.captureScreenshot`)
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type {
// ── Configuration ──────────────────────────────────────────────────────────────
export {
resolveConfig,
validateEngineConfigSnapshot,
DEFAULT_CONFIG,
scaleProtocolTimeoutForComposition,
shouldClampToScreenshotForConcreteGpu,
Expand Down
13 changes: 13 additions & 0 deletions packages/producer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export {
type RenderPerfSummary,
type ProgressCallback,
} from "./services/renderOrchestrator.js";
export {
RENDER_REQUEST_VERSION,
createRenderRequest,
distributedConfigFromRequest,
parseRenderRequest,
renderConfigFromRequest,
renderRequestFromDistributedConfig,
serializeRenderRequest,
type CreateRenderRequestInput,
type DistributedRenderOptions,
type RenderRequest,
type RenderRequestOptions,
} from "./renderRequest.js";
export {
type BrowserDiagnosticSummary,
type RenderCaptureObservability,
Expand Down
Loading
Loading