// fallow-ignore-file code-duplication complexity /** * Chunk Encoder Service * * Encodes captured frames into video using FFmpeg. * Supports CPU (libx264) and GPU encoding. */ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs"; import { join, dirname, extname } from "path"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js"; import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js"; import { withEvenDimensionPad } from "../utils/evenDimensions.js"; import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js"; import { extractAudioMetadata } from "../utils/ffprobe.js"; import { type Fps, fpsToFfmpegArg } from "@hyperframes/core"; import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; import { appendVp9CpuUsedArg } from "./vp9Options.js"; export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; export const ENCODER_PRESETS = { draft: { preset: "ultrafast", quality: 28, codec: "h264" as const }, standard: { preset: "medium", quality: 18, codec: "h264" as const }, high: { preset: "slow", quality: 15, codec: "h264" as const }, }; export interface EncoderPreset { preset: string; quality: number; codec: "h264" | "h265" | "vp9" | "prores"; pixelFormat: string; hdr?: { transfer: HdrTransfer }; } function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs: number): string { if (!timedOut) return error; // Two independent reports of this exact timeout, both resolved by env vars // that already exist but aren't named anywhere the user would see them at // the point of failure — they had to go find FFMPEG_ENCODE_TIMEOUT_MS and // PRODUCER_ENABLE_CHUNKED_ENCODE themselves. Name both here instead of // just stating what happened. return ( `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms). ` + "Long or high-frame-count renders may need more time: set FFMPEG_ENCODE_TIMEOUT_MS " + "to a higher value (ms), or set PRODUCER_ENABLE_CHUNKED_ENCODE=true to encode in " + "smaller chunks instead of one long-running ffmpeg process." ); } function isAacSidecar(audioPath: string): boolean { return extname(audioPath).toLowerCase() === ".aac"; } const KNOWN_NON_AAC_AUDIO_EXTENSIONS = new Set([ ".flac", ".mp3", ".oga", ".ogg", ".opus", ".wav", ".webm", ]); export interface MuxVideoWithAudioOptions extends Partial< Pick > { /** * Codec of the sidecar audio when the caller already knows it. HyperFrames * render paths pass the mixed AAC sidecar by contract, so muxing should not * depend on the file extension alone. */ audioCodec?: "aac"; /** Preserve a priming edit list known to have been created by AAC re-encoding. */ preserveAudioPrimingEditList?: boolean; } async function shouldCopyAacSidecar( audioPath: string, options: MuxVideoWithAudioOptions | undefined, ) { if (options?.audioCodec === "aac" || isAacSidecar(audioPath)) return true; const audioExtension = extname(audioPath).toLowerCase(); if (KNOWN_NON_AAC_AUDIO_EXTENSIONS.has(audioExtension)) return false; try { const metadata = await extractAudioMetadata(audioPath); return metadata.audioCodec === "aac"; } catch { // Preserve the pre-existing fallback for invalid or unprobeable sidecars: // let the final ffmpeg transcode path surface the actionable mux error. return false; } } /** * Get encoder preset for a given quality and output format. * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR); * MOV uses ProRes 4444 with alpha for editor-compatible transparency. */ export function getEncoderPreset( quality: "draft" | "standard" | "high", format: "mp4" | "webm" | "mov" = "mp4", hdr?: { transfer: HdrTransfer }, ): EncoderPreset { const base = ENCODER_PRESETS[quality]; if (format === "webm") { return { preset: base.preset === "ultrafast" ? "realtime" : "good", quality: base.quality, codec: "vp9", pixelFormat: "yuva420p", }; } if (format === "mov") { return { preset: "4444", quality: base.quality, codec: "prores", pixelFormat: "yuva444p10le", }; } if (hdr) { return { preset: base.preset === "ultrafast" ? "fast" : base.preset, quality: base.quality, codec: "h265", pixelFormat: "yuv420p10le", hdr, }; } return { ...base, pixelFormat: "yuv420p" }; } // Re-export GPU utilities so existing consumers that import from chunkEncoder still work. export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js"; export function buildEncoderArgs( options: EncoderOptions, inputArgs: string[], outputPath: string, gpuEncoder: GpuEncoder = null, ): string[] { const { fps, codec = "h264", preset = "medium", quality = 23, bitrate, pixelFormat = "yuv420p", vp9CpuUsed, useGpu = false, } = options; // libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd // produce a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block // inside the bitstream) which confuses HDR-aware players. Strip hdr and // log a warning so the caller picks h265 (the SDR-tagged output is honest). if (options.hdr && codec === "h264") { console.warn( "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " + "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.", ); options = { ...options, hdr: undefined }; } const args: string[] = [...inputArgs, "-r", fpsToFfmpegArg(fps)]; const shouldUseGpu = useGpu && gpuEncoder !== null; if (codec === "h264" || codec === "h265") { if (shouldUseGpu) { const encoderName = getGpuEncoderName(gpuEncoder, codec); args.push("-c:v", encoderName); switch (gpuEncoder) { case "nvenc": args.push("-preset", mapPresetForGpuEncoder("nvenc", preset)); if (bitrate) args.push("-b:v", bitrate); else args.push("-cq", String(quality)); break; case "videotoolbox": if (bitrate) args.push("-b:v", bitrate); else { const vtQuality = Math.max(0, Math.min(100, 100 - quality * 2)); args.push("-q:v", String(vtQuality)); } args.push("-allow_sw", "1"); break; case "vaapi": args.unshift("-vaapi_device", "/dev/dri/renderD128"); args.push("-vf", "format=nv12,hwupload"); if (bitrate) args.push("-b:v", bitrate); else args.push("-qp", String(quality)); break; case "qsv": args.push("-preset", mapPresetForGpuEncoder("qsv", preset)); if (bitrate) args.push("-b:v", bitrate); else args.push("-global_quality", String(quality)); break; case "amf": if (bitrate) args.push("-b:v", bitrate); else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality)); break; } // Same B-frame story as the SW branch below — nvenc/amf emit B-frames // by default (qsv via b_strategy, vaapi too), and the negative-DTS // freeze hits the same downstream players. The unconditional // `-avoid_negative_ts make_zero` near the bottom of this function // covers the mux level, but we belt-and-suspenders the encoder too // so even tools that consume the chunk file directly (without going // through our mux step) play correctly. videotoolbox doesn't accept // `-bf` so it's skipped — videotoolbox h264 also doesn't emit // negative DTS in practice on macOS Sonoma+. if ( codec === "h264" && (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi" || gpuEncoder === "amf") ) { args.push("-bf", "0"); if (gpuEncoder === "qsv") { args.push("-b_strategy", "0"); } } } else { const encoderName = codec === "h264" ? "libx264" : "libx265"; args.push("-c:v", encoderName, "-preset", preset); if (bitrate) args.push("-b:v", bitrate); else args.push("-crf", String(quality)); // Closed-GOP / forced-keyframe args so an external orchestrator can // ffmpeg-concat chunk files with `-c copy`. Without these, libx264 / // libx265 emit open-GOP frames with mid-chunk scenecut keyframes; the // first frame of each chunk isn't an independently-decodable IDR and // concat-copy playback freezes at chunk seams on some decoders. const lockGop = options.lockGopForChunkConcat === true; let gop = 0; if (lockGop) { if ( typeof options.gopSize !== "number" || !Number.isFinite(options.gopSize) || options.gopSize <= 0 ) { throw new Error( `[chunkEncoder] lockGopForChunkConcat=true requires a positive integer gopSize (received ${String(options.gopSize)})`, ); } gop = Math.floor(options.gopSize); args.push( "-g", String(gop), "-keyint_min", String(gop), "-sc_threshold", "0", "-force_key_frames", `expr:eq(mod(n,${gop}),0)`, ); } // Disable B-frames. Standard h264 with B-frames produces negative DTS // at the start of the stream (the first B-frame's decode order is // "before" the first I-frame's presentation time). VS Code's video // preview, several browser