diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index e5617485d3..90dc83e63a 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -78,6 +78,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 }, runtime: { logger?: unknown }) => { + const { engineConfig, ...options } = request.options; + return { ...options, producerConfig: engineConfig, logger: runtime.logger }; + }, + ), createRenderJob: vi.fn((config: Record) => { producerState.createdJobs.push(config); return { config, progress: 100, outcome: "completed", warnings: [] }; diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 8cfeac8571..7aa68bdb6f 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1386,6 +1386,8 @@ async function renderDocker( bestEffort: options.bestEffort, experimentalFastCapture: options.experimentalFastCapture, pageNavigationTimeoutMs: options.pageNavigationTimeoutMs, + protocolTimeoutMs: options.protocolTimeout, + playerReadyTimeoutMs: options.playerReadyTimeout, }, }); @@ -1479,33 +1481,38 @@ 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, - 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, + debug: options.debug, + strictness: options.bestEffort === false ? "strict" : "best-effort", + }, }); + const job = producer.createRenderJob(producer.renderConfigFromRequest(request, { logger })); const onProgress = options.quiet ? undefined diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 97b4e4ade8..aa98b2e26f 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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 diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 5f30f3e337..beb4d9cc94 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -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; } /** @@ -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)] + : []), ]; } diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index b98b3e8c90..1de6369a68 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -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, diff --git a/packages/producer/src/renderRequest.test.ts b/packages/producer/src/renderRequest.test.ts new file mode 100644 index 0000000000..f88e47c258 --- /dev/null +++ b/packages/producer/src/renderRequest.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONFIG } from "@hyperframes/engine"; +import { + createRenderRequest, + distributedConfigFromRequest, + parseRenderRequest, + renderConfigFromRequest, + renderRequestFromDistributedConfig, + serializeRenderRequest, +} from "./renderRequest.js"; + +const originalForceScreenshot = process.env.PRODUCER_FORCE_SCREENSHOT; + +afterEach(() => { + if (originalForceScreenshot === undefined) delete process.env.PRODUCER_FORCE_SCREENSHOT; + else process.env.PRODUCER_FORCE_SCREENSHOT = originalForceScreenshot; +}); + +function request() { + return createRenderRequest({ + projectDir: "/project", + outputPath: "/output/video.mp4", + engineConfig: { ...DEFAULT_CONFIG, protocolTimeout: 123_456 }, + options: { + fps: { num: 30, den: 1 }, + quality: "high", + format: "mp4", + workers: 3, + useGpu: true, + strictness: "best-effort", + entryFile: "compositions/main.html", + crf: 18, + videoFrameFormat: "png", + hdrMode: "force-sdr", + variables: { title: "Hello", nested: { count: 2 } }, + outputResolution: "landscape-4k", + distributed: { + width: 1920, + height: 1080, + codec: "h264", + chunkSize: 120, + maxParallelChunks: 8, + cfr: true, + }, + }, + }); +} + +describe("RenderRequest", () => { + it("round-trips through JSON without dropping nested options", () => { + const value = request(); + expect(parseRenderRequest(serializeRenderRequest(value))).toEqual(value); + }); + + it("adapts the same request to local and distributed execution", () => { + const value = request(); + const local = renderConfigFromRequest(value); + const distributed = distributedConfigFromRequest(value); + + expect(local).toMatchObject({ + fps: { num: 30, den: 1 }, + quality: "high", + format: "mp4", + variables: value.options.variables, + producerConfig: { protocolTimeout: 123_456 }, + }); + expect(distributed).toMatchObject({ + fps: 30, + width: 1920, + height: 1080, + format: "mp4", + chunkSize: 120, + variables: value.options.variables, + producerConfig: { protocolTimeout: 123_456 }, + }); + }); + + it("round-trips distributed adapter fields back into the shared request", () => { + const value = request(); + const distributed = distributedConfigFromRequest(value); + const reconstructed = renderRequestFromDistributedConfig({ + projectDir: value.projectDir, + outputPath: value.outputPath, + config: distributed, + }); + + expect(reconstructed.options).toMatchObject({ + fps: value.options.fps, + quality: value.options.quality, + format: value.options.format, + entryFile: value.options.entryFile, + variables: value.options.variables, + distributed: value.options.distributed, + }); + }); + + it("snapshots environment-derived engine options once at the boundary", () => { + process.env.PRODUCER_FORCE_SCREENSHOT = "true"; + const value = createRenderRequest({ + projectDir: "/project", + outputPath: "/output/video.mp4", + options: { fps: { num: 30, den: 1 }, quality: "standard", format: "mp4" }, + }); + process.env.PRODUCER_FORCE_SCREENSHOT = "false"; + + expect(value.options.engineConfig.forceScreenshot).toBe(true); + expect(renderConfigFromRequest(value).producerConfig?.forceScreenshot).toBe(true); + }); + + it("rejects unsupported distributed fps before an adapter launch", () => { + const value = request(); + value.options.fps = { num: 30_000, den: 1_001 }; + expect(() => distributedConfigFromRequest(value)).toThrow("does not support fps"); + }); +}); diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts new file mode 100644 index 0000000000..b88effe430 --- /dev/null +++ b/packages/producer/src/renderRequest.ts @@ -0,0 +1,229 @@ +import { resolveConfig, type EngineConfig, type VideoFrameFormat } from "@hyperframes/engine"; +import type { CanvasResolution, Fps } from "@hyperframes/core"; +import type { ProducerLogger } from "./logger.js"; +import type { RenderConfig } from "./services/renderOrchestrator.js"; +import type { DistributedRenderConfig } from "./services/distributed/plan.js"; +import type { SerializableDistributedRenderConfig } from "./services/distributed/renderConfigValidation.js"; + +export const RENDER_REQUEST_VERSION = 1 as const; + +export interface DistributedRenderOptions { + width: number; + height: number; + codec?: "h264" | "h265"; + chunkSize?: number; + maxParallelChunks?: number; + targetChunkFrames?: number; + runtimeCap?: DistributedRenderConfig["runtimeCap"]; + rejectOnSystemFonts?: boolean; + failClosedFontFetch?: boolean; + cfr?: boolean; + planDirSizeLimitBytes?: number; +} + +/** JSON-safe render intent shared by local, Docker, server and cloud adapters. */ +export interface RenderRequestOptions { + fps: Fps; + quality: "draft" | "standard" | "high"; + format: NonNullable; + gifLoop?: number; + workers?: number; + useGpu?: boolean; + debug?: boolean; + strictness?: RenderConfig["strictness"]; + entryFile?: string; + crf?: number; + videoBitrate?: string; + videoFrameFormat?: VideoFrameFormat; + hdrMode?: RenderConfig["hdrMode"]; + variables?: Record; + outputResolution?: CanvasResolution; + engineConfig: EngineConfig; + distributed?: DistributedRenderOptions; +} + +export interface RenderRequest { + version: typeof RENDER_REQUEST_VERSION; + projectDir: string; + outputPath: string; + options: RenderRequestOptions; +} + +export interface CreateRenderRequestInput { + projectDir: string; + outputPath: string; + options: Omit; + engineConfig?: EngineConfig; + engineOverrides?: Partial; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertPositiveFps(options: Record): void { + const fps = options.fps; + if ( + !isPlainObject(fps) || + !Number.isInteger(fps.num) || + !Number.isInteger(fps.den) || + (fps.num as number) <= 0 || + (fps.den as number) <= 0 + ) { + throw new Error("Render request fps must be a positive rational"); + } +} + +function assertRequestOptions(options: unknown): asserts options is RenderRequestOptions { + if (!isPlainObject(options)) throw new Error("Render request options must be an object"); + assertPositiveFps(options); + if (!["draft", "standard", "high"].includes(String(options.quality))) { + throw new Error("Render request quality is invalid"); + } + if (!["mp4", "webm", "mov", "png-sequence", "gif"].includes(String(options.format))) { + throw new Error("Render request format is invalid"); + } + if (!isPlainObject(options.engineConfig)) { + throw new Error("Render request must contain a resolved engineConfig snapshot"); + } + if (options.variables !== undefined && !isPlainObject(options.variables)) { + throw new Error("Render request variables must be a JSON object"); + } +} + +function assertNonEmptyPath(value: unknown, field: "projectDir" | "outputPath"): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Render request ${field} must be a non-empty string`); + } +} + +function assertRenderRequest(value: unknown): asserts value is RenderRequest { + if (!isPlainObject(value) || value.version !== RENDER_REQUEST_VERSION) { + const version = isPlainObject(value) ? value.version : undefined; + throw new Error(`Unsupported render request version: ${String(version)}`); + } + assertNonEmptyPath(value.projectDir, "projectDir"); + assertNonEmptyPath(value.outputPath, "outputPath"); + assertRequestOptions(value.options); +} + +export function parseRenderRequest(serialized: string | unknown): RenderRequest { + const value = typeof serialized === "string" ? JSON.parse(serialized) : serialized; + assertRenderRequest(value); + return value; +} + +export function serializeRenderRequest(request: RenderRequest): string { + assertRenderRequest(request); + return JSON.stringify(request); +} + +export function createRenderRequest(input: CreateRenderRequestInput): RenderRequest { + const request = { + version: RENDER_REQUEST_VERSION, + projectDir: input.projectDir, + outputPath: input.outputPath, + options: { + ...input.options, + engineConfig: input.engineConfig ?? resolveConfig(input.engineOverrides), + }, + } satisfies RenderRequest; + // A JSON round-trip both proves serializability and detaches caller-owned + // variables/config objects before asynchronous adapters receive them. + return parseRenderRequest(JSON.stringify(request)); +} + +export function renderConfigFromRequest( + request: RenderRequest, + runtime: { logger?: ProducerLogger } = {}, +): RenderConfig { + const { engineConfig, distributed: _distributed, ...options } = request.options; + return { + ...options, + producerConfig: engineConfig, + logger: runtime.logger, + }; +} + +function distributedFps(fps: Fps): 24 | 30 | 60 { + if (fps.den === 1 && (fps.num === 24 || fps.num === 30 || fps.num === 60)) return fps.num; + throw new Error(`Distributed render does not support fps ${fps.num}/${fps.den}`); +} + +export function distributedConfigFromRequest( + request: RenderRequest, + runtime: { logger?: ProducerLogger; abortSignal?: AbortSignal } = {}, +): DistributedRenderConfig { + const options = request.options; + const distributed = options.distributed; + if (!distributed) throw new Error("Render request is missing distributed options"); + if (options.format === "gif") throw new Error("Distributed render does not support gif"); + if (options.hdrMode === "force-hdr") { + throw new Error("Distributed render does not support force-hdr"); + } + return { + fps: distributedFps(options.fps), + width: distributed.width, + height: distributed.height, + format: options.format, + codec: distributed.codec, + quality: options.quality, + crf: options.crf, + bitrate: options.videoBitrate, + videoFrameFormat: options.videoFrameFormat, + outputResolution: options.outputResolution, + chunkSize: distributed.chunkSize, + maxParallelChunks: distributed.maxParallelChunks, + targetChunkFrames: distributed.targetChunkFrames, + runtimeCap: distributed.runtimeCap, + rejectOnSystemFonts: distributed.rejectOnSystemFonts, + failClosedFontFetch: distributed.failClosedFontFetch, + hdrMode: options.hdrMode === "auto" ? "auto" : "force-sdr", + cfr: distributed.cfr, + logger: runtime.logger, + producerConfig: options.engineConfig, + engineConfig: options.engineConfig, + entryFile: options.entryFile, + abortSignal: runtime.abortSignal, + planDirSizeLimitBytes: distributed.planDirSizeLimitBytes, + variables: options.variables, + }; +} + +export function renderRequestFromDistributedConfig(input: { + projectDir: string; + outputPath: string; + config: SerializableDistributedRenderConfig; +}): RenderRequest { + const { config } = input; + return createRenderRequest({ + projectDir: input.projectDir, + outputPath: input.outputPath, + engineConfig: config.engineConfig ?? resolveConfig(), + options: { + fps: { num: config.fps, den: 1 }, + quality: config.quality ?? "standard", + format: config.format, + crf: config.crf, + videoBitrate: config.bitrate, + videoFrameFormat: config.videoFrameFormat, + outputResolution: config.outputResolution, + hdrMode: config.hdrMode ?? "force-sdr", + entryFile: config.entryFile, + variables: config.variables, + distributed: { + width: config.width, + height: config.height, + codec: config.codec, + chunkSize: config.chunkSize, + maxParallelChunks: config.maxParallelChunks, + targetChunkFrames: config.targetChunkFrames, + runtimeCap: config.runtimeCap, + rejectOnSystemFonts: config.rejectOnSystemFonts, + failClosedFontFetch: config.failClosedFontFetch, + cfr: config.cfr, + planDirSizeLimitBytes: config.planDirSizeLimitBytes, + }, + }, + }); +} diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 9c796fb260..d03b495692 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -44,6 +44,7 @@ import { resolveRenderPaths } from "./utils/paths.js"; import { defaultLogger, type ProducerLogger } from "./logger.js"; import { Semaphore } from "./utils/semaphore.js"; import { parseFps, normalizeResolutionFlag, type CanvasResolution } from "@hyperframes/core"; +import { createRenderRequest, renderConfigFromRequest } from "./renderRequest.js"; // --------------------------------------------------------------------------- // Types @@ -198,21 +199,25 @@ function parseRenderOverrides(body: Record): { * the sync (`render`) and streaming (`render-stream`) handlers so the field * set — including `variables` and `outputResolution` — stays in one place. */ -function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) { - return { - fps: input.fps, - quality: input.quality, - format: input.format, - workers: input.workers, - useGpu: input.useGpu, - debug: input.debug, - strictness: input.strictness, - entryFile: input.entryFile, - variables: input.variables, - outputResolution: input.outputResolution, - videoFrameFormat: input.videoFrameFormat, - logger: log, - }; +function buildRenderJobConfig(input: RenderInput, outputPath: string, log: ProducerLogger) { + const request = createRenderRequest({ + projectDir: input.projectDir, + outputPath, + options: { + fps: input.fps, + quality: input.quality, + format: input.format ?? "mp4", + workers: input.workers, + useGpu: input.useGpu, + debug: input.debug, + strictness: input.strictness, + entryFile: input.entryFile, + variables: input.variables, + outputResolution: input.outputResolution, + videoFrameFormat: input.videoFrameFormat, + }, + }); + return renderConfigFromRequest(request, { logger: log }); } /** @@ -618,7 +623,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle quality: input.quality, }); - const job = createRenderJob(buildRenderJobConfig(input, log)); + const job = createRenderJob(buildRenderJobConfig(input, absoluteOutputPath, log)); try { await executeRenderJob( @@ -694,7 +699,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle log.info("render-stream started", { requestId, projectDir: input.projectDir }); - const job = createRenderJob(buildRenderJobConfig(input, log)); + const job = createRenderJob(buildRenderJobConfig(input, absoluteOutputPath, log)); const abortController = new AbortController(); const onRequestAbort = () => abortController.abort(new RenderCancelledError("request_aborted")); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index cdb5a8058d..32a1c61e0c 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -200,6 +200,8 @@ export interface DistributedRenderConfig { cfr?: boolean; logger?: ProducerLogger; + /** JSON-safe engine snapshot carried across cloud/process boundaries. */ + engineConfig?: EngineConfig; /** Optional engine config override (env vars are not read when provided). */ producerConfig?: EngineConfig; /** Entry HTML file relative to `projectDir`. Defaults to `"index.html"`. */ @@ -749,7 +751,7 @@ export async function plan( } }; const cfg: EngineConfig = { - ...(config.producerConfig ?? resolveConfig()), + ...(config.producerConfig ?? config.engineConfig ?? resolveConfig()), browserGpuMode: "software", forceScreenshot: false, }; @@ -768,7 +770,7 @@ export async function plan( strictness: config.strictness, entryFile: config.entryFile ?? "index.html", logger: config.logger, - producerConfig: config.producerConfig, + producerConfig: cfg, }); const entryFile = config.entryFile ?? "index.html"; const htmlPath = join(projectDir, entryFile); diff --git a/packages/producer/src/services/distributed/renderConfigValidation.ts b/packages/producer/src/services/distributed/renderConfigValidation.ts index 2a2398523b..0d31685667 100644 --- a/packages/producer/src/services/distributed/renderConfigValidation.ts +++ b/packages/producer/src/services/distributed/renderConfigValidation.ts @@ -203,6 +203,9 @@ export function validateDistributedRenderConfig( if (config.variables !== undefined) { validateVariablesPayload(config.variables); } + if (config.engineConfig !== undefined) { + walkVariables(config.engineConfig, "config.engineConfig", new WeakSet()); + } return config; }