/** * Activity A of the distributed render pipeline. * * `plan(projectDir, config, planDir)` composes the existing render stages * (compile → probe → extract videos → audio → freeze) into a self-contained * `/` directory tree that downstream chunk workers consume: * * / * ├── plan.json * ├── compiled/ # compileForRender output (self-contained) * ├── video-frames/ # per-video JPEG sequences (dereferenced) * ├── audio.aac # only when composition has audio * └── meta/ * ├── composition.json * ├── encoder.json # LockedRenderConfig * └── chunks.json * * Pure function over local paths. No networking. Two invocations with the * same inputs produce the same `planHash` — adapters use that contract to * short-circuit `plan()` on workflow replay. * * Banned configurations (GPU encode, hardware browser GL, system primary * fonts) are rejected at plan time via `planValidation.ts` so chunk workers * never have to handle them. */ import { type CanvasResolution } from "@hyperframes/core"; import { type EngineConfig } from "@hyperframes/engine"; import { type ProducerLogger } from "../../logger.js"; import { type ChunkSliceJson } from "../render/stages/freezePlan.js"; import { type DistributedFormat } from "./shared.js"; /** * Caller-supplied configuration for a distributed render. `fps`, `width`, * `height`, and `format` are required; everything else carries a default * sensible for AWS Lambda fan-out. */ export interface DistributedRenderConfig { /** Integer frame rate. Distributed renders only accept integer fps; the in-process renderer's `Fps` rational handles NTSC. */ fps: 24 | 30 | 60; width: number; height: number; /** * Output container format. HDR mp4 is not supported in distributed * mode — `plan()` refuses it up front with a typed * `FormatNotSupportedInDistributedError`. The in-process renderer * supports it. * * `"webm"` (VP9 + Opus) is distributed-supported via closed-GOP * concat-copy: `lockGopForChunkConcat=true` forces a keyframe at every * chunk boundary and disables libvpx-vp9's alt-ref frames so chunk * files stitch losslessly. See `chunkEncoder.ts` for the VP9 args and * `tests/distributed/_smoke/webm-concat-copy.test.ts` for the gating * experiment that proved the contract. */ format: DistributedFormat; /** * Codec selection for `format: "mp4"`. `"h264"` (the default) → libx264 + * yuv420p; `"h265"` → libx265 + yuv420p with closed-GOP keyint params * (`min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1`) so chunked * concat-copy round-trips losslessly the same way h264 does. Ignored for * `format: "mov"` (always ProRes 4444) and `format: "png-sequence"` * (no encoder). Passing `codec` with a non-mp4 format throws at plan * time so caller errors surface immediately rather than producing a * silently-wrong planDir. */ codec?: "h264" | "h265"; quality?: "draft" | "standard" | "high"; /** Constant-rate-factor override; mutually exclusive with `bitrate`. */ crf?: number; /** Target video bitrate (e.g. `"10M"`); mutually exclusive with `crf`. */ bitrate?: string; /** Output resolution preset; engages Chrome `deviceScaleFactor` supersampling. */ outputResolution?: CanvasResolution; /** * Frames per chunk. When explicitly set, that value is used and * `chunkCount = min(maxParallelChunks, ceil(totalFrames / chunkSize))` * — useful when the caller wants a specific per-chunk runtime * regardless of fan-out. When `undefined` (the default), `plan()` * auto-sizes from `maxParallelChunks` so the caller's fan-out * intent is honored: `effectiveChunkSize = max(MIN_CHUNK_SIZE, * ceil(totalFrames / maxParallelChunks))`. The auto-size floor * (`MIN_CHUNK_SIZE = 10`) keeps per-chunk fixed overhead from * swamping the parallelism gain on tiny renders. * * `effectiveChunkSize` also drives `LockedRenderConfig.gopSize` — every * chunk's first frame is an IDR keyframe, so smaller chunks mean a * tighter GOP and larger encoded files. Callers who optimize for * output bytes (rather than wall-clock parallelism) should pass an * explicit `chunkSize` matching their target GOP — e.g. `240` for the * old 8-second-GOP behavior. */ chunkSize?: number; /** Default `16`. Caps long renders to fewer-but-longer chunks for operational fairness. */ maxParallelChunks?: number; /** Runtime hint; consumed by future per-runtime budget checks. The current implementation records the value but does not enforce. */ runtimeCap?: "lambda" | "temporal" | "cloud-run-job" | "k8s-job" | "none"; /** * Reject compositions whose primary font-family resolves to a host-OS / * generic family. Default `true` for distributed renders — overriding to * `false` is unsupported and exists only as an escape hatch for tests. */ rejectOnSystemFonts?: boolean; /** * Threaded into the `injectDeterministicFontFaces` font loader. Default * `true` — distributed renders must not silently fall back to system fonts. */ failClosedFontFetch?: boolean; /** HDR is not supported in distributed mode; `force-hdr` trips a `FormatNotSupportedInDistributedError`. Defaults to `force-sdr`. */ hdrMode?: "auto" | "force-sdr"; /** * Opt-in exact-CFR re-encode at the assemble stage. When `true`, the * stitched output is re-encoded once with `-fps_mode cfr -r ` so * the stream-level `avg_frame_rate` matches the container's * `r_frame_rate` exactly (and the file duration is exact, not * PTS-derived). Useful for downstream consumers that strict-check * `avg_frame_rate` or ms-precision duration. Default `false` retains * the existing `-c copy` stitch path, which is faster and lossless. * mp4 only — webm / mov stream-copy paths already produce exact * avg_frame_rate. Consumed by `assemble`; does not affect `planHash` * (chunks render identically; only the final stitch step differs). */ cfr?: boolean; logger?: ProducerLogger; /** Optional engine config override (env vars are not read when provided). */ producerConfig?: EngineConfig; /** Entry HTML file relative to `projectDir`. Defaults to `"index.html"`. */ entryFile?: string; /** Caller-supplied AbortSignal. Threaded through compile / probe / extract / audio stages. */ abortSignal?: AbortSignal; /** * Hard ceiling on `/` size in bytes; trips a non-retryable * `PLAN_TOO_LARGE` error after freeze. Defaults to * {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB — fits inside AWS Lambda's * 10 GB `/tmp` budget alongside the chunk worker's frame buffer + * ffmpeg working set). Adapters that deploy onto storage with * tighter ceilings can pass a smaller cap; tests pass a tiny cap to * exercise the throw path. */ planDirSizeLimitBytes?: number; /** * Render-time variable overrides for the composition. Snapshotted into * `meta/encoder.json` at plan time and re-injected by every chunk * worker as `window.__hfVariables` before the first capture, mirroring * the in-process renderer's * `RenderConfig.variables` → `CaptureOptions.variables` path. The * runtime helper `getVariables()` merges these over the declared * defaults from ``. * * Folded into `planHash`: different variables produce different hashes * because rendered frames depend on the injected values. Must be a * JSON-serializable plain object — `freezePlan`'s canonical-JSON pass * throws on non-serializable values (functions, Symbols, BigInts) when * the variables reach this layer. Adapters that ship to Lambda (the * `@hyperframes/aws-lambda` SDK) also validate the shape client-side * before any AWS call so the rejection lands at the SDK boundary * rather than mid-plan; the producer-side throw is the fallback. */ variables?: Record; } /** * Result of {@link plan}. The `planHash` is the content-addressed identifier * that adapters key replay short-circuits off of. */ export interface PlanResult { planDir: string; planHash: string; chunkCount: number; totalFrames: number; fps: 24 | 30 | 60; width: number; height: number; format: DistributedFormat; ffmpegVersion: string; producerVersion: string; } /** * Top-level directory names skipped by the `projectDir → planDir/compiled/` * pre-seed copy. Real projects often contain `node_modules/`, VCS metadata, * and harness artifacts that have no business in a planDir — they bloat * the 2 GB planDir cap and slow the S3/Lambda round-trip for no benefit. * Matched against the path relative to `projectDir` so a `projectDir` * whose absolute path happens to contain one of these names (e.g. * `~/work/output/comp/`) doesn't false-positive-skip the entire copy. */ export declare const PLAN_PROJECT_DIR_SKIP_SEGMENTS: ReadonlySet; /** * Default chunk size in frames (~8s @ 30fps; fits Lambda's 15-min cap). * Used when the caller explicitly passes this value. When `chunkSize` is * `undefined`, `plan()` auto-sizes from `maxParallelChunks` instead. */ export declare const DEFAULT_CHUNK_SIZE = 240; /** Default cap on parallel chunks for operational fairness across renders. */ export declare const DEFAULT_MAX_PARALLEL_CHUNKS = 16; /** * Floor for the auto-sized `chunkSize` when the caller leaves it * `undefined`. Anything smaller hits a per-chunk fixed-overhead wall * (worker boot + plan download + planHash recompute + ffmpeg init) that * outweighs the parallelism gain on tiny renders. */ export declare const MIN_CHUNK_SIZE = 10; /** * Default hard ceiling on `/` size in bytes. 2 GB fits inside * AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames * and ffmpeg's temporary files. Compositions that exceed this have to * fall back to the in-process renderer until per-chunk video-frame * slicing lands. */ export declare const PLAN_DIR_SIZE_LIMIT_BYTES: number; /** * Non-retryable error code raised when `plan()` produces a planDir whose * total size exceeds the configured limit. Workflow adapters key retry * policies off `code` — the planDir would fail the same way on every * retry, so the failure must not auto-retry. */ export declare const PLAN_TOO_LARGE = "PLAN_TOO_LARGE"; /** Typed error raised when the produced planDir exceeds {@link PLAN_DIR_SIZE_LIMIT_BYTES}. */ export declare class PlanTooLargeError extends Error { readonly code: typeof PLAN_TOO_LARGE; readonly sizeBytes: number; readonly limitBytes: number; constructor(sizeBytes: number, limitBytes: number); } /** * Non-retryable error code raised when `plan()` is asked for an output * format that distributed mode doesn't support (currently: HDR mp4). The * same config would fail on every retry, so the failure must not * auto-retry. */ export declare const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED"; /** * Typed error raised by `plan()` for outputs that distributed mode * refuses to ship. * * - mp4 + HDR (PQ / HLG) — chunked HDR pre-extract + HDR signaling * re-apply on the assembled file is not implemented yet. * * The in-process renderer (`executeRenderJob`) handles it natively. * * WebM was previously refused here; v0.7+ supports it via closed-GOP * concat-copy. See {@link DistributedRenderConfig.format} for the * supported set and {@link rejectUnsupportedDistributedFormat} for the * gate. */ export declare class FormatNotSupportedInDistributedError extends Error { readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED; readonly format: string; readonly reason: string; constructor(format: string, reason: string); } /** * Reject formats the distributed pipeline cannot ship (HDR mp4 only — * webm is supported as of v0.7 via closed-GOP concat-copy). * * Throws {@link FormatNotSupportedInDistributedError} with a message * naming the rejected format. Runs at the very top of `plan()` so a * banned input never produces a partial planDir. * * Exported so adapters can call the same gate at their own input layer * (Step Functions input validation, Temporal workflow start) before the * activity even runs — the resulting non-retryable error then matches * what `plan()` would have thrown. */ export declare function rejectUnsupportedDistributedFormat(config: Pick): void; /** * Walk `/` depth-first and sum all regular file sizes. Symlinks * are not traversed — they shouldn't appear inside a planDir to begin with * (the extract stage materializes them), and following them could push the * walker outside the planDir. */ export declare function measurePlanDirBytes(planDir: string): number; /** * Compute `(chunkCount, effectiveChunkSize)` from total frames and the * caller's chunking knobs. The operative chunk size is * `resolvedChunkSize` — equal to `configChunkSize` when the caller * passes one, otherwise auto-sized from `maxParallelChunks`: * * resolvedChunkSize = configChunkSize ?? max(MIN_CHUNK_SIZE, ceil(totalFrames / maxParallelChunks)) * chunkCount = min(maxParallelChunks, ceil(totalFrames / resolvedChunkSize)) * effectiveChunkSize = max(resolvedChunkSize, ceil(totalFrames / chunkCount)) * chunkCount = min(chunkCount, ceil(totalFrames / effectiveChunkSize)) // drop empty trailing slice * * Long renders auto-rescale to fewer-but-longer chunks rather than * fragmenting infinitely. Returned `chunkCount >= 1` (`totalFrames === 0` * is rejected upstream); `effectiveChunkSize >= resolvedChunkSize`. * * The auto-sizer (triggered when `configChunkSize` is `undefined`) honors * the caller's fan-out intent: passing `maxParallelChunks=16` without * `chunkSize` produces 16 chunks (subject to the `MIN_CHUNK_SIZE` floor * on tiny renders). Explicit numbers, including `240`, take precedence. */ export declare function resolveChunkPlan(totalFrames: number, configChunkSize: number | undefined, maxParallelChunks: number): { chunkCount: number; effectiveChunkSize: number; }; /** * Slice `totalFrames` into `chunkCount` consecutive ranges. Each chunk gets * `effectiveChunkSize` frames except the last, which absorbs the remainder * so the union is exactly `[0, totalFrames)`. `endFrame` is the EXCLUSIVE * upper bound — chunk workers iterate `i in [startFrame, endFrame)`. */ export declare function buildChunkSlices(totalFrames: number, chunkCount: number, effectiveChunkSize: number): ChunkSliceJson[]; /** * Activity A of the distributed render pipeline. Produces a self-contained * `/` from a project + config. See module docstring for the * directory layout. */ export declare function plan(projectDir: string, config: DistributedRenderConfig, planDir: string): Promise; //# sourceMappingURL=plan.d.ts.map