/** * Activity B of the distributed render pipeline. * * `renderChunk(planDir, chunkIndex, outputChunkPath)` validates the planDir * against the worker's environment, captures the chunk's frame range, and * encodes a single closed-GOP video chunk (or, for png-sequence, a directory * of PNGs). The output is byte-identical across retries on the same worker * and PSNR-equivalent across workers — that contract is what makes Temporal * activity retries safe. * * Pure function over local paths. No networking. Spins up its own headless * Chrome + file server scoped to the chunk; tears them down before * returning. The caller is responsible for moving `outputChunkPath` to its * orchestration-level storage (S3 / GCS / EFS / …). * * Hard contracts: * - The worker re-applies `meta/encoder.json.runtimeEnv` into * `process.env` BEFORE the file server starts so the served HTML's * `RENDER_MODE_SCRIPT` sees the same env it would have seen on the * controller. * - Browser is launched with `browserGpuMode: "software"` and verified * against `chrome://gpu` via `assertSwiftShader` — a non-SwiftShader * backend trips a non-retryable `BROWSER_GPU_NOT_SOFTWARE`. * - The file server serves with the seeded-random shim * (`buildVirtualTimeShim({ seedRandomFromFrame: true })`) so any * composition that uses `Math.random` / `crypto.getRandomValues` * produces byte-identical pixels per `(planDir, chunkIndex)`. * - No `lastFrameCache` priming: every frame seeks fresh DOM so the * cache is never read, and priming would deadlock the compositor. * - The chunk's encode runs with `lockGopForChunkConcat: true` and * `gopSize === framesInChunk` so concat-copy at assemble time is safe. * * Every determinism toggle above is opt-in — only this primitive enables them. * In-process renders (`executeRenderJob`) leave them off. */ import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { extname, join } from "node:path"; import { assertSwiftShader, type BeforeCaptureHook, BROWSER_GPU_NOT_SOFTWARE, calculateOptimalWorkers, type CaptureOptions, type CaptureSession, closeCaptureSession, createCaptureSession, createFrameLookupTable, createVideoFrameInjector, type EngineConfig, type ExtractedFrames, getEncoderPreset, initializeSession, readWebGlVendorInfoFromCanvas, resolveConfig, } from "@hyperframes/engine"; import { defaultLogger } from "../../logger.js"; import { runEncodeStage } from "../render/stages/encodeStage.js"; import { runCaptureStage } from "../render/stages/captureStage.js"; import { resolveVideoCaptureBeyondViewport } from "../render/captureBeyondViewport.js"; import { createCapturePlan } from "../render/capturePlan.js"; import { type ChunkSliceJson, type LockedRenderConfig, recomputePlanHashFromPlanDir, } from "../render/stages/freezePlan.js"; import { sha256Hex } from "../render/stages/planHash.js"; import { applyRuntimeEnvSnapshot } from "../render/runtimeEnvSnapshot.js"; import { buildVirtualTimeShim, closeFileServerSafely, createFileServer, type FileServerHandle, } from "../fileServer.js"; import { buildSyntheticRenderJob, type DistributedFormat, PLAN_VIDEOS_META_RELATIVE_PATH, type PlanVideosJson, readFfmpegVersion, } from "./shared.js"; /** * Non-retryable error codes raised when the planDir is structurally * malformed, semantically out of range, or fingerprints differently from * what the controller wrote. Each is distinct so adapter retry policies * can route them independently — e.g. `MISSING_PLAN_ARTIFACT` may point * to a partial S3 download that a retry could heal, while * `PLAN_HASH_MISMATCH` strictly indicates cross-version drift that * retries won't fix. */ export const FFMPEG_VERSION_MISMATCH = "FFMPEG_VERSION_MISMATCH"; export const PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH"; export const MISSING_PLAN_ARTIFACT = "MISSING_PLAN_ARTIFACT"; export const CHUNK_INDEX_OUT_OF_RANGE = "CHUNK_INDEX_OUT_OF_RANGE"; export const MISSING_RUNTIME_ENV_SNAPSHOT = "MISSING_RUNTIME_ENV_SNAPSHOT"; const LEGACY_DISTRIBUTED_VP9_CPU_USED = 2; export type RenderChunkValidationCode = | typeof FFMPEG_VERSION_MISMATCH | typeof PLAN_HASH_MISMATCH | typeof MISSING_PLAN_ARTIFACT | typeof CHUNK_INDEX_OUT_OF_RANGE | typeof MISSING_RUNTIME_ENV_SNAPSHOT | typeof BROWSER_GPU_NOT_SOFTWARE; /** * Typed non-retryable error raised by `renderChunk` when the planDir is * malformed or the worker's runtime doesn't match the planDir's * controller-side fingerprint. Workflow adapters key retry policies off * `code` — most of these failures will not heal on retry. */ export class RenderChunkValidationError extends Error { readonly code: RenderChunkValidationCode; constructor(code: RenderChunkValidationCode, message: string) { super(message); this.name = "RenderChunkValidationError"; this.code = code; } } /** * Result of {@link renderChunk}. The `sha256` field is the byte hash of the * primary output (the mp4/mov file, or, for png-sequence, the sorted-frame * fingerprint). Retries on the same `(planDir, chunkIndex)` MUST produce * the same `sha256` — that contract is the byte-identical-retry axis. */ export interface ChunkResult { /** Absolute path the encoded chunk was written to (file or directory). */ outputPath: string; /** `"file"` for mp4/mov; `"frame-dir"` for png-sequence. */ outputKind: "file" | "frame-dir"; framesEncoded: number; sha256: string; durationMs: number; /** * Stage wall-clock split of `durationMs`, for separating per-chunk fixed * overhead from frame-proportional work in fleet cost models: * * - `planHashMs` — full planDir content-hash recomputation (validation). * - `sessionBootMs` — sequential-branch Chrome boot + SwiftShader assert + * composition warmup. Stays 0 when `workers > 1` (each parallel worker * boots inside the capture stage instead). * - `captureStageMs` — the capture stage call; includes per-worker session * boots in the parallel branch. * - `encodeStageMs` — the encode stage call (single ffmpeg invocation, or * the frame-dir arrangement for png-sequence). * * The remainder of `durationMs` is validation + file-server setup + output * hashing + cleanup. */ planHashMs: number; sessionBootMs: number; captureStageMs: number; encodeStageMs: number; /** Capture workers used for this chunk (`calculateOptimalWorkers` result). */ workers: number; /** * Path to a sidecar JSON containing per-chunk perf counters. Adapters * upload this alongside the chunk so per-chunk regressions are * inspectable without the workflow having to carry the payload. */ perfPath: string; } /** * Rebuild the engine's in-memory `ExtractedFrames[]` from the on-disk * planDir layout. `/video-frames//` holds the numbered * frame files plan() extracted; this lists each dir and rebuilds the * 0-based `framePaths` Map that `FrameLookupTable` / `videoFrameInjector` * both index against — the consumer is * `videoFrameExtractor.ts:getFrameAtTime`, which floors `localTime * fps` * to a 0-based index and reads `framePaths.get(frameIndex)`. Any drift * from that key convention silently drops every `