import { type RunFfmpegOptions } from './ffmpeg.js'; /** * Segment-count threshold at which `auto` strategy switches from the * concat demuxer to the concat filter. Mirrors the Python lesson that the * demuxer accumulates audible AV drift across "8+ segments" * (ffmpeg_wrapper.concat_via_filter docstring). At or above this many segments * `auto` prefers the filter path. */ export declare const FILTER_FALLBACK_SEGMENT_THRESHOLD = 8; /** Default background-music mix level (nex_assemble.py --music-volume default 0.05). */ export declare const DEFAULT_MUSIC_VOLUME = 0.05; /** Default tail fade-out for the music bed in seconds (add_background_music default 3.0). */ export declare const DEFAULT_MUSIC_FADE_OUT_SEC = 3; /** AAC bitrate for re-encode paths (filter concat + music mix). Matches the Python "192k". */ export declare const STANDARD_AUDIO_BITRATE = "192k"; /** Stable list of the named grade ids (for CLI validation / docs). */ export declare const GRADE_FILTER_IDS: string[]; /** * Resolve a named grade id to its FFmpeg filter chain. Returns '' for an * undefined/empty/unknown id (the prep pass then applies no grade) — callers that * want a hard failure on an unknown id should validate against {@link GRADE_FILTER_IDS}. */ export declare function resolveGradeFilter(id: string | undefined): string; /** Which concat path to use. `auto` picks demuxer/filter by segment count. */ export type ConcatStrategy = 'demuxer' | 'filter' | 'auto'; export interface MusicMixSettings { /** Path to the background-music track. Looped under the narration. */ trackPath: string; /** Mix level for the music bed (0..1). Defaults to {@link DEFAULT_MUSIC_VOLUME}. */ volume?: number; /** Tail fade-out duration in seconds. Defaults to {@link DEFAULT_MUSIC_FADE_OUT_SEC}. */ fadeOutSec?: number; /** * Voice-forward mix (loudnorm the video's narration + sidechain-duck the music * under it + final limiter). Forwarded to {@link BuildMusicMixOptions.voiceForward}. * Default off → byte-identical legacy bed mix. */ voiceForward?: boolean; } export interface StitchInput { /** * Ordered body segment paths (the slide segments). These sit between the * optional intro and outro segments. From 3e every segment conforms to * 1280×720@24 / H.264 / AAC 44100 stereo, so demuxer concat is valid. */ segments: string[]; /** Optional ordered intro segment paths, prepended before `segments`. */ intro?: string[]; /** Optional ordered outro segment paths, appended after `segments`. */ outro?: string[]; /** Where the final stitched MP4 is written. */ outputPath: string; /** * Path for the concat-demuxer list file. Defaults to `concat.txt` alongside * the output. Only used by the demuxer path. */ concatListPath?: string; /** * Concat strategy. `auto` (default) uses the demuxer up to * {@link FILTER_FALLBACK_SEGMENT_THRESHOLD} segments, then the filter. * bunty maps to `auto`/`demuxer`; nex maps to `filter`. */ concatStrategy?: ConcatStrategy; /** Optional background-music bed (the nex-brand knob). Omit for bunty. */ music?: MusicMixSettings; /** * Optional EXTRA global audio layers (dialogue / sfx / additional voice) * mixed over the concatenated video at the stitch step, BEYOND the `music` * bed above. Presence-driven and additive: when this is empty/undefined the * stitch is byte-identical to legacy (the music-only {@link buildMusicMixArgs} * path, or the no-audio path). When non-empty, the music bed (if any) is * prepended as a `music` layer and the whole set is mixed via * {@link buildMultiLayerMixArgs}. See assemble.ts Step 6 (dialogue/sfx wiring). */ audioLayers?: AudioLayer[]; /** * Sidechain-duck every `music` layer under the narration/dialogue layers in * the MULTI-LAYER mix (`audioLayers` path). The multi-layer analogue of * `music.voiceForward`: without it a soundtrack bed sits at full level over * a narration layer. Only consulted when `audioLayers` is non-empty; unset → * byte-identical legacy multi-layer mix. */ duckMusicUnderVoice?: boolean; /** * Optional per-clip tail cut (WS9). When set, each ordered segment is * re-encoded with `-t ` before concat, dropping the dead / * freeze frames AI generators append. Omit for byte-identical legacy behavior. */ clipMaxSeconds?: number; /** * Optional letterbox normalization (WS9). When set (e.g. `2.39:1`), each * segment is scaled+padded onto the {@link letterboxCanvas} (default * 1280×720) before concat, producing cinematic bars. Omit to disable. */ letterboxRatio?: string; /** * Canvas for {@link letterboxRatio}. Defaults to the 3e segment standard * (1280×720) so normalized segments stay uniform for demuxer concat. */ letterboxCanvas?: { width: number; height: number; }; /** * Path to a `.cube`/`.3dl` LUT applied to every segment via `lut3d` (e.g. a * Kodak Vision3 500T film-stock LUT). Omit to skip. Triggers the prep pass. */ gradeLut?: string; /** * Named color grade applied to every segment (see {@link GRADE_FILTER_IDS}). * A real post transform on the footage, not a prompt hint. Omit to skip. */ gradeId?: string; /** * Per-segment grade overrides, indexed to {@link orderedSegments} (intro + * body + outro). A defined entry overrides {@link gradeId} for that segment — * this is how the narrative color language is realized (e.g. cool-steel for the * calm scenes, crimson-threat for the breach, electric-blue for the resolution). */ segmentGradeIds?: Array; /** * Optional reading-holds (motion-comic). For each listed segment index (into * {@link orderedSegments}: intro+body+outro), a short held, gently-drifting * still of that segment's readable FIRST frame is prepended BEFORE its motion * plays — so viewers can read text-dense info panels (a cast roster, a strategy * map) before the animation takes over. Omit / empty `segments` → byte-identical * legacy (no holds). When any hold is present the concat uses the FILTER path * (re-encode) so the freshly-encoded hold clips concat cleanly with the segments. */ readingHold?: ReadingHoldSettings; } export interface ReadingHoldSettings { /** Hold duration in seconds before each designated segment. Default 3.5. */ holdSec?: number; /** Segment indices (into orderedSegments) that get a reading hold. Empty ⇒ no holds. */ segments: number[]; } export interface BuildReadingHoldOptions { /** Hold duration in seconds. Default 3.5. */ holdSec?: number; /** Output width. Default 1280. */ width?: number; /** Output height. Default 720. */ height?: number; /** Output fps. Default 24. */ fps?: number; } /** * Build ffmpeg args (PURE) that turn a video segment's FIRST frame into a short * held, gently-drifting still clip (a "reading hold"), with a silent stereo audio * track so it concats cleanly. `select=eq(n,0)` grabs frame 0; `zoompan` holds it * for `holdSec` with a faint slow zoom (alive, not frozen); encoded to the segment * standard (libx264 crf20 / AAC 44100 stereo @ fps). `-y` is prepended by runFfmpeg. */ export declare function buildReadingHoldArgs(segmentPath: string, outputPath: string, opts?: BuildReadingHoldOptions): string[]; export interface StitchPlannedStep { /** What this step does. */ kind: 'segment-prep' | 'reading-hold' | 'concat-demuxer' | 'concat-filter' | 'music-mix' | 'multi-layer-mix'; /** The ffmpeg args (everything after the binary and the auto-prepended `-y`). */ args: string[]; /** The output this step writes. */ outputPath: string; } export interface StitchResult { status: 'complete' | 'dry-run'; /** Final MP4 path. */ outputPath: string; /** Ordered list of every segment that went into the concat. */ orderedSegments: string[]; /** Which concat path was actually used. */ concatStrategy: 'demuxer' | 'filter'; /** Whether a music bed was mixed. */ music: boolean; /** The planned ffmpeg command sequence (always populated, incl. dry-run). */ plan: StitchPlannedStep[]; /** Final video duration in milliseconds (0 on dry-run — no probe). */ durationMs: number; } /** Assemble the full ordered segment list: intro + body + outro. */ export declare function orderedSegments(input: StitchInput): string[]; /** * Choose the effective concat path. `demuxer`/`filter` are honored directly; * `auto` flips to the filter at {@link FILTER_FALLBACK_SEGMENT_THRESHOLD} * segments (the drift lesson from the Python). */ export declare function selectConcatStrategy(strategy: ConcatStrategy, segmentCount: number): 'demuxer' | 'filter'; /** Render the concat-demuxer list file body: one `file ''` line per segment. */ export declare function buildConcatListContent(segments: string[]): string; /** * Build the concat-DEMUXER ffmpeg args (PURE). The primary path. * * Ported VERBATIM from stitch_bunty._concat_via_demuxer (L240): * ffmpeg -y -f concat -safe 0 -i concat.txt -c copy output * (`-y` is prepended by `runFfmpeg`, so it is NOT included here.) * * A single ffmpeg invocation regardless of segment count — survives the * sandbox's per-session FFmpeg limit. Requires all segments to share encoding * params (true for 3e segments). */ export declare function buildConcatDemuxerArgs(_segments: string[], concatListPath: string, outputPath: string): string[]; export interface BuildConcatFilterOptions { /** H.264 CRF (Python default 20). */ crf?: number; /** AAC bitrate (Python default "192k"). */ audioBitrate?: string; /** Audio sample rate Hz (Python default 44100). */ sampleRate?: number; /** Audio channel count (Python default 2). */ channels?: number; } /** * Build the concat-FILTER fallback ffmpeg args (PURE). For 8+ segments where * demuxer drift accumulates, or when a segment has incompatible codec params. * * Ported VERBATIM from ffmpeg_wrapper.concat_via_filter (L296-326): * -i f0 -i f1 ... -i f{n-1} * -filter_complex "[0:v][0:a][1:v][1:a]...concat=n=N:v=1:a=1[outv][outa]" * -map [outv] -map [outa] * -c:v libx264 -preset fast -crf 20 * -c:a aac -b:a 192k -ar 44100 -ac 2 * -movflags +faststart output */ export declare function buildConcatFilterArgs(segments: string[], outputPath: string, opts?: BuildConcatFilterOptions): string[]; export interface BuildSegmentPrepOptions { /** Per-clip tail cut in seconds (WS9 trimTailArgs). Omit/<=0 disables the trim. */ clipMaxSeconds?: number; /** Letterbox target ratio label (WS9 letterboxFilter). Omit/'' disables the filter. */ letterboxRatio?: string; /** Path to a `.cube`/`.3dl` LUT applied via `lut3d` (e.g. a Kodak Vision3 500T LUT). Omit to skip. */ gradeLut?: string; /** Resolved FFmpeg grade filter chain (from {@link resolveGradeFilter}). Omit/'' to skip. */ gradeFilter?: string; /** Letterbox canvas width. Default 1280 (3e segment standard). */ width?: number; /** Letterbox canvas height. Default 720. */ height?: number; /** Output frame rate. Default 24. */ fps?: number; /** H.264 CRF. Default 20. */ crf?: number; /** AAC bitrate. Default {@link STANDARD_AUDIO_BITRATE}. */ audioBitrate?: string; /** Audio sample rate Hz. Default 44100. */ sampleRate?: number; /** Audio channels. Default 2. */ channels?: number; /** * Pad the audio track with trailing silence to exactly the video duration * (`apad` + `-shortest`), so the prepped segment is A/V-aligned and a demuxer * `-c copy` concat of such segments cannot accumulate drift (the "echo" / * overlapping-narration bug from the film builds). It also TRIMS audio that * runs past the video, preventing one scene's narration bleeding into the next. * Default true; `stitch()` auto-disables it for a segment with no audio stream * (probed on real runs — `apad` needs an audio source on stricter ffmpeg * builds). The re-encode already emits AAC, so this only adds the pad/shortest, * not a second pass. */ padAudioToVideoDuration?: boolean; } /** * Build the per-segment normalization ffmpeg args (PURE, WS9). Re-encodes one * clip to the 3e segment standard (1280×720@24 / libx264 preset-fast crf20 / AAC * 44100 stereo) while optionally trimming its tail ({@link trimTailArgs}) and * letterboxing it onto a canvas ({@link letterboxFilter}). Re-encoding to the * standard keeps the normalized segments uniform, so the demuxer `-c copy` concat * stays valid. By default the audio is also padded/trimmed to the video length * ({@link BuildSegmentPrepOptions.padAudioToVideoDuration}) so segments are * A/V-aligned and a `-c copy` concat cannot accumulate drift. `-t` is placed just * before the output path so it caps the OUTPUT. */ export declare function buildSegmentPrepArgs(segment: string, outputPath: string, opts?: BuildSegmentPrepOptions): string[]; export interface BuildMusicMixOptions { /** Mix level for the music bed (0..1). Default {@link DEFAULT_MUSIC_VOLUME}. */ volume?: number; /** Tail fade-out seconds. Default {@link DEFAULT_MUSIC_FADE_OUT_SEC}. */ fadeOutSec?: number; /** * Total video duration in seconds — used to compute the fade-out start * (`total - fadeOut`) and the `-t` cap. The caller probes this via * `ffprobeDuration`. Defaults to 0 (fade starts at 0 / `-t 0`), only used by * the dry-run / pure-builder path where the duration is not yet known. */ totalDurationSec?: number; /** * Voice-forward mix (default false → byte-identical legacy). When true, the * video's own audio (`[0:a]`, i.e. the baked per-scene narration) is loudnorm'd * to a broadcast target, the music bed is sidechain-ducked under it, and the * final mix is limited — so narration is never buried beneath the bed. This is * the film-proven chain (loudnorm I=-15:TP=-1.5 + sidechaincompress + alimiter). * assemble enables it by default whenever per-scene narration was produced. */ voiceForward?: boolean; } /** * Build the background-music mix ffmpeg args (PURE). Loops the music under the * narration at a low volume with a tail fade-out, then mixes via amix. * * Ported VERBATIM from assembly_utils.add_background_music (L408-435): * -i video -stream_loop -1 -i music * -filter_complex "[0:a]volume=1.0[v]; * [1:a]volume={vol},afade=t=out:st={fade_start}:d={fade_out}[m]; * [v][m]amix=inputs=2:duration=first:dropout_transition=600:normalize=0[a]" * -map 0:v -map [a] * -c:v copy -c:a aac -b:a 192k * -movflags +faststart * -t {total} output * * fade_start = max(0, total - fade_out). `dropout_transition=600` prevents early * audio cutoff on silent sections; `normalize=0` keeps the narration at full * level while the music stays at `volume`. */ export declare function buildMusicMixArgs(videoPath: string, musicPath: string, outputPath: string, opts?: BuildMusicMixOptions): string[]; /** * A single audio layer to mix under or alongside the video's native audio. */ export interface AudioLayer { /** Path to the audio track file. */ trackPath: string; /** * Semantic role of this layer. * - `'music'` — background music bed (low volume, looped). * - `'narration'` — voice-over narration track (full volume, no loop). * - `'dialogue'` — on-screen dialogue (full volume, no loop). * - `'sfx'` — sound-effect track (mixed alongside, NOT a ducking * sidechain signal; does not by itself enable ducking). */ role: 'music' | 'narration' | 'dialogue' | 'sfx'; /** Mix level (0..1). Defaults: music → {@link DEFAULT_MUSIC_VOLUME}, voice → 1.0. */ volume?: number; /** * Whether to loop this track to fill the video duration. * Default: `true` for `music` layers, `false` for `narration`/`dialogue`. */ loop?: boolean; } export interface BuildMultiLayerMixOptions { /** * When `true` AND at least one `narration`/`dialogue` layer is present, * music layers are sidechained under the voice via `sidechaincompress` * (threshold −12 dB / 0.25 linear, ratio 4:1, attack 5 ms, release 200 ms) * so the music ducks automatically whenever voice is active. * Falls back to static-volume amix when there are no voice layers. * Default: `false`. */ duckMusicUnderVoice?: boolean; /** * Global music-bed volume applied to every `music` layer that does not * set its own `volume`. Individual `layer.volume` takes precedence. * Default: {@link DEFAULT_MUSIC_VOLUME} (0.05). */ musicVolume?: number; /** Reserved for callers that need to override the ffmpeg binary path. */ ffmpegBin?: string; } /** * Build ffmpeg args that mix the video's own audio together with N additional * audio layers. **PURE** — returns only the arg-string array; never spawns * processes, reads files, or has side-effects. * * ## Input numbering * - Input 0 : the video file (carries video + its original audio). * - Inputs 1…N : the supplied `layers`, in order. * * ## Filter graph — static-volume path (default) * ``` * [1:a]volume=[l1]; [2:a]volume=[l2]; … * [0:a][l1][l2]…amix=inputs=N+1:duration=first:dropout_transition=600:normalize=0[a] * ``` * * ## Filter graph — ducking path (`duckMusicUnderVoice=true` + voice layer present) * ``` * per-layer volume chains as above … * [voice_labels…]amix=…[voice] (or direct passthrough for single voice) * [voice]asplit=M+1[vs0]…[vsM-1][vsfinal] * [l_music0][vs0]sidechaincompress=…[duck0]; [l_music1][vs1]sidechaincompress=…[duck1]; … * [0:a][duck0][duck1]…[vsfinal][sfx…]amix=inputs=M+2+S:…[a] * ``` * where M = number of music layers and S = number of sfx layers. * SFX layers are mixed in at their own volume but are NEVER used as a * sidechain signal and do NOT by themselves enable ducking (only * `narration`/`dialogue` layers count as voice for the `duckMusicUnderVoice` * gate). * * ## Output mapping * `-map 0:v -map [a] -c:v copy -c:a aac -b:a 192k -movflags +faststart ` */ export declare function buildMultiLayerMixArgs(videoPath: string, layers: AudioLayer[], outputPath: string, opts?: BuildMultiLayerMixOptions): string[]; export interface StitchOptions extends RunFfmpegOptions { /** Override the ffprobe binary (forwarded to `ffprobeDuration`). */ ffprobeBin?: string; /** * Skip the pre-concat MP4-validity guard. Default OFF (validation ON) for * real runs. The guard probes each input segment with {@link isValidMp4} and * fails fast on a truncated/no-moov MP4 instead of letting ffmpeg concat die * with a cryptic "Invalid data found when processing input". Always skipped on * dry-run (no real files to probe). */ skipSegmentValidation?: boolean; } /** * Orchestrate the stitch: write the concat list (demuxer path), pick the * demuxer-vs-filter path, run via `runFfmpeg`, optionally mix a music bed, and * return the final MP4 path + a plan of the executed command sequence. * * On `dryRun`, returns the planned command sequence WITHOUT writing the concat * list, spawning ffmpeg, or probing durations (music fade-start is computed * from 0). This is the path unit tests use. * * NOTE: the real-spawn path. The final-MP4 quality check is a HUMAN integration * checkpoint — out of scope here. */ export declare function stitch(input: StitchInput, opts?: StitchOptions): Promise; //# sourceMappingURL=stitch.d.ts.map