/** * Activity C of the distributed render pipeline. * * `assemble(planDir, chunkPaths, audioPath, outputPath)` stitches per-chunk * outputs into the final deliverable. For mp4 / mov / webm this is * `ffmpeg -f concat -c copy` (free of re-encode loss because every * chunk's first frame is an IDR keyframe — the chunk encoder sets * `lockGopForChunkConcat` to enforce this, which for libvpx-vp9 also * disables alt-ref frames so concat seams remain independently * decodable). For png-sequence chunks (each chunk is a directory of * frames) this is a straight directory merge with global re-numbering. * * Mux + faststart for mp4 / mov / webm go through the engine's * `muxVideoWithAudio` + `applyFaststart` helpers — same path the * in-process renderer uses; we just feed concat output rather than * streaming-encoder output. (Faststart is a no-op for webm and mov — * applyFaststart copies the input verbatim.) Audio length is * pad-or-trimmed to `frameCount / fps` via * `padOrTrimAudioToVideoFrameCount` so the mux step doesn't introduce * sub-millisecond drift at the end of long renders. * * Pure function over local paths. No networking. The caller is responsible * for moving `outputPath` to its orchestration-level storage. */ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine"; import { fpsToFfmpegArg } from "@hyperframes/core"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { formatExportFrameName } from "../../utils/paths.js"; import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js"; import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocolV1 } from "./planProtocol.js"; import { validatePlanV2MaterializedTarget } from "./planV2.js"; import type { DistributedFormat } from "./shared.js"; /** * Result of {@link assemble}. `fileSize` reflects the final file on disk * (mp4/mov) or the cumulative byte total of the frame directory * (png-sequence). */ export interface AssembleResult { outputPath: string; durationMs: number; framesEncoded: number; fileSize: number; } /** Shape of the planDir's top-level `plan.json` — only the fields `assemble` needs. */ interface PlanJsonForAssemble { protocol?: unknown; planHash: string; totalFrames: number; hasAudio: boolean; dimensions: { fpsNum: number; fpsDen: number; width: number; height: number; format: DistributedFormat; }; } /** * Assemble the chunk outputs into a single deliverable. * * @param planDir — absolute path to the planDir produced by `plan()`. * @param chunkPaths — ordered chunk outputs, length === `chunks.json` length. * For mp4/mov each entry is a path to an encoded chunk file; for * png-sequence each entry is a path to a directory of frames. * @param audioPath — `/audio.aac` for mux'd formats. Pass `null` * when the composition has no audio (or `assemble` is being called for a * format whose audio is muxed elsewhere). `assemble` always normalizes * audio length against the assembled video's frame count when * `audioPath` is non-null. * @param outputPath — final on-disk output (file for mp4/mov; directory * for png-sequence — created if missing). */ export async function assemble( planDir: string, chunkPaths: readonly string[], audioPath: string | null, outputPath: string, options?: { logger?: ProducerLogger; abortSignal?: AbortSignal; /** * Opt-in exact-CFR re-encode. When `true`, the assembled video is * re-encoded once at the end of the concat/single-chunk step with * `-fps_mode cfr -r ` so the stream-level `avg_frame_rate` * matches the container's `r_frame_rate` exactly (and the file's * duration lands on the requested `frameCount / fps` to ms * precision, with no PTS-derived drift). Trade-off: ~2-5x the * stitch time for a 60s 1080p clip plus second-generation H.264 * quality loss (negligible at `-crf 18` but non-zero). Default * `false` preserves the existing `-c copy` behavior. mp4 only * (libx264); webm / mov pass through unchanged because their * stream-copy paths don't exhibit the same avg-frame-rate drift. */ cfr?: boolean; }, ): Promise { const start = Date.now(); const log = options?.logger ?? defaultLogger; const abortSignal = options?.abortSignal; const cfr = options?.cfr === true; // ── 1. Validate planDir manifest matches chunkPaths shape ────────────── const planJsonPath = join(planDir, "plan.json"); const chunksJsonPath = join(planDir, "meta", "chunks.json"); if (!existsSync(planJsonPath)) { throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`); } const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble; readPlanProtocolV1(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler); validatePlanV2MaterializedTarget(planDir, { role: "assembler" }); if (!existsSync(chunksJsonPath)) { throw new Error(`[assemble] planDir missing meta/chunks.json: ${chunksJsonPath}`); } const chunks = JSON.parse(readFileSync(chunksJsonPath, "utf-8")) as ChunkSliceJson[]; if (chunkPaths.length !== chunks.length) { throw new Error( `[assemble] chunkPaths length (${chunkPaths.length}) does not match ` + `chunks.json length (${chunks.length}). Adapters must pass one path ` + `per chunk, ordered by index.`, ); } for (const path of chunkPaths) { if (!existsSync(path)) { throw new Error(`[assemble] chunk path does not exist: ${path}`); } } if (plan.dimensions.format === "png-sequence") { // ── 2a. png-sequence: merge frame directories with global re-numbering return mergePngFrameDirs(chunkPaths, outputPath, plan.totalFrames, audioPath, start); } // ── 2b. mp4 / mov / webm: concat-copy then mux + faststart ──────────── if (!existsSync(dirname(outputPath))) { mkdirSync(dirname(outputPath), { recursive: true }); } const workDir = `${outputPath}.assemble-work`; if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true }); mkdirSync(workDir, { recursive: true }); try { const concatOutputPath = join(workDir, `concat.${plan.dimensions.format}`); const fpsArg = fpsToFfmpegArg({ num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen, }); // Single-chunk renders bypass the concat demuxer entirely. ffmpeg's // concat demuxer with a one-entry list re-runs as a straight remux of // the single source, and in that path the input-side `-r` flag does // not consistently override the source's PTS-derived r_frame_rate // (observed: `359/12` carrying through to the output container while // the equivalent multi-chunk path produces `30/1` exact). Running a // direct `-c copy` remux with `-r ` as an output flag gives the // muxer the authoritative rate to stamp into the container without // touching the encoded stream. Multi-chunk renders continue through // the concat demuxer where the existing `-r` input flag works. if (chunkPaths.length === 1) { const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-r", fpsArg, "-y", concatOutputPath]; const remuxResult = await runFfmpeg(remuxArgs, { signal: abortSignal }); if (!remuxResult.success) { throw new Error( `[assemble] ffmpeg single-chunk remux failed (exit ${remuxResult.exitCode}): ` + `${remuxResult.stderr.slice(-400)}`, ); } } else { // Concat list file — one `file ''` per chunk, in order. ffmpeg's // concat demuxer escapes single quotes via `'\''`; we replicate that // here so chunk paths containing quotes don't break the parser. const concatListPath = join(workDir, "concat-list.txt"); const concatBody = chunkPaths .map((path) => `file '${path.replace(/'/g, "'\\''")}'`) .join("\n"); writeFileSync(concatListPath, `${concatBody}\n`, "utf-8"); // Set the exact input framerate so the concat demuxer doesn't // PTS-average a fractional rational like `360000/12001` instead // of `30/1` into the output container metadata. `-c copy` is // retained; no re-encode. const concatArgs = [ "-r", fpsArg, "-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-y", concatOutputPath, ]; const concatResult = await runFfmpeg(concatArgs, { signal: abortSignal }); if (!concatResult.success) { throw new Error( `[assemble] ffmpeg concat-copy failed (exit ${concatResult.exitCode}): ` + `${concatResult.stderr.slice(-400)}`, ); } } // ── 2c. Optional exact-CFR re-encode ────────────────────────────────── // The concat / single-chunk step produces a stream-copy intermediate // whose container `r_frame_rate` is exact but whose stream-level // `avg_frame_rate` stays PTS-derived (concat-copy carries each chunk's // original PTS unmodified). For consumers that strict-check // `avg_frame_rate` or ms-precision duration (broadcast workflows, // frame-accurate compositors, some third-party transcoders), an // opt-in re-encode with `-fps_mode cfr -r ` lands the stream's // avg-frame-rate on the requested rational exactly. Restricted to // mp4 / libx264 — webm and mov go through their own stream-copy // paths that don't exhibit the same avg-frame-rate drift, and h265 // mp4 would silently transcode to h264 under the hardcoded // `-c:v libx264` re-encode (a typed throw is preferable to silent // codec loss). let postConcatPath = concatOutputPath; if (cfr) { if (plan.dimensions.format !== "mp4") { throw new Error( `[assemble] cfr=true is only supported for format="mp4" (got ` + `"${plan.dimensions.format}"). Stream-copy paths for webm and mov ` + `already produce exact avg_frame_rate; cfr re-encode is not needed.`, ); } // Read `meta/encoder.json` to detect the chunk encoder. The cfr // re-encode hardcodes `-c:v libx264`; pairing it with h265 chunks // would silently transcode them to h264. Throw a typed error so the // caller surfaces the conflict instead of producing a wrong-codec // deliverable. const encoderJsonPath = join(planDir, "meta", "encoder.json"); if (!existsSync(encoderJsonPath)) { throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`); } const encoderJson = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as { encoder?: string; }; if (encoderJson.encoder === "libx265-software") { throw new Error( `[assemble] cfr=true is not yet supported with codec: "h265". The ` + `cfr re-encode pass uses libx264 and would silently transcode the ` + `h265 chunks. Either disable cfr or render with codec: "h264".`, ); } const cfrOutputPath = join(workDir, `cfr.${plan.dimensions.format}`); const cfrArgs = [ "-i", concatOutputPath, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-fps_mode", "cfr", "-r", fpsArg, "-y", cfrOutputPath, ]; const cfrResult = await runFfmpeg(cfrArgs, { signal: abortSignal }); if (!cfrResult.success) { throw new Error( `[assemble] ffmpeg cfr re-encode failed (exit ${cfrResult.exitCode}): ` + `${cfrResult.stderr.slice(-400)}`, ); } postConcatPath = cfrOutputPath; log.info("[assemble] cfr re-encode applied", { format: plan.dimensions.format, fpsNum: plan.dimensions.fpsNum, fpsDen: plan.dimensions.fpsDen, }); } // ── 3. Audio: pad-or-trim then mux ──────────────────────────────────── let normalizedAudio: { path: string; preserveAudioPrimingEditList: boolean; } | null = null; if (audioPath !== null && existsSync(audioPath)) { const paddedAudioPath = join(workDir, "audio-padded.m4a"); const padTrimResult = await padOrTrimAudioToVideoFrameCount({ videoPath: postConcatPath, audioPath, outputPath: paddedAudioPath, signal: abortSignal, }); if (!padTrimResult.success) { throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`); } normalizedAudio = { path: paddedAudioPath, preserveAudioPrimingEditList: padTrimResult.operation !== "copy", }; log.info("[assemble] audio normalized for mux", { operation: padTrimResult.operation, targetDurationSeconds: padTrimResult.targetDurationSeconds, sourceDurationSeconds: padTrimResult.sourceDurationSeconds, }); } // mux + faststart paths mirror `runAssembleStage` for in-process renders // (`render/stages/assembleStage.ts`). We can't call that stage directly // because it operates on a `RenderJob` and emits `updateJobStatus` // payloads — the distributed activity has no job to thread through. const muxOutputPath = normalizedAudio !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath; if (normalizedAudio !== null) { const muxResult = await muxVideoWithAudio( postConcatPath, normalizedAudio.path, muxOutputPath, abortSignal, { audioCodec: "aac", preserveAudioPrimingEditList: normalizedAudio.preserveAudioPrimingEditList, }, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, ); if (!muxResult.success) { throw new Error(`[assemble] audio mux failed: ${muxResult.error}`); } } // applyFaststart is a no-op for `.mov` (it copies the input to output); // we still call it so the success path produces `outputPath` regardless. const faststartResult = await applyFaststart( muxOutputPath, outputPath, abortSignal, undefined, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen, }, ); if (!faststartResult.success) { throw new Error(`[assemble] faststart failed: ${faststartResult.error}`); } } finally { try { rmSync(workDir, { recursive: true, force: true }); } catch (err) { log.warn("[assemble] failed to remove work dir", { workDir, error: err instanceof Error ? err.message : String(err), }); } } const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0; return { outputPath, durationMs: Date.now() - start, framesEncoded: plan.totalFrames, fileSize, }; } /** * Merge per-chunk PNG frame directories into a single directory with * globally-incrementing frame numbers. Each chunk's `frame_NNNNNN.png` files * (which `renderChunk` writes normalized to zero per chunk) are re-numbered * into the merged output so consumers see one continuous numbered sequence. * * Audio is intentionally NOT muxed here — png-sequence has no container. * If `audioPath` is non-null we copy it alongside as `audio.aac` so callers * who need to re-mux later (After Effects, Nuke, ffmpeg image2 + audio) can * find it. */ function mergePngFrameDirs( chunkPaths: readonly string[], outputPath: string, totalFrames: number, audioPath: string | null, startTimeMs: number, ): AssembleResult { if (existsSync(outputPath)) rmSync(outputPath, { recursive: true, force: true }); mkdirSync(outputPath, { recursive: true }); let globalIdx = 0; for (const chunkDir of chunkPaths) { if (!statSync(chunkDir).isDirectory()) { throw new Error( `[assemble] png-sequence chunk must be a directory: ${chunkDir} (got a file)`, ); } const frames = readdirSync(chunkDir) .filter((name) => name.endsWith(".png")) .sort(); if (frames.length === 0) { throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`); } for (const frame of frames) { const dst = join(outputPath, formatExportFrameName(globalIdx, "png")); cpSync(join(chunkDir, frame), dst); globalIdx += 1; } } if (globalIdx !== totalFrames) { // Don't throw — surface as a warning. Some compositions report total // frame count via the duration math (`ceil(duration * fps)`) but the // actual captured frame count can differ by ±1 across hosts in edge // cases. The merged sequence is still complete; consumers should rely // on the on-disk count. console.warn( `[assemble] png-sequence frame count mismatch: merged ${globalIdx} frames vs ` + `plan.totalFrames=${totalFrames}. Using on-disk count.`, ); } // Pad-or-trim is encoder-side (audio length normalization for muxed // containers); png-sequence has no encoder, so we copy the audio // verbatim. The sidecar matches the in-process png-sequence convention. if (audioPath !== null && existsSync(audioPath)) { const sidecar = join(outputPath, "audio.aac"); cpSync(audioPath, sidecar); } let fileSize = 0; for (const name of readdirSync(outputPath)) { try { fileSize += statSync(join(outputPath, name)).size; } catch { // ignore } } return { outputPath, durationMs: Date.now() - startTimeMs, framesEncoded: globalIdx, fileSize, }; }