Skip to main content

ez_ffmpeg/core/writer/
mod.rs

1//! Push raw video frames from Rust code into a full FFmpeg pipeline.
2//!
3//! [`VideoWriter`](crate::VideoWriter) is a narrow, ergonomic facade over the
4//! crate's existing [`Output`](crate::Output) surface: you build frames in
5//! memory (any packed or planar `AVPixelFormat` — `rgba`, `rgb24`, `gray8`,
6//! `yuv420p`, `nv12`, …, delivered as tightly packed plane bytes) and push
7//! them in, and they flow through the ordinary filter → encode → mux
8//! pipeline. Because the destination is a plain [`Output`](crate::Output),
9//! the writer accepts any video encoder and container the linked FFmpeg build
10//! supports for that pairing, plus filter chains (one video input, one video
11//! output, with a directed input-to-output path), GPU frame pipelines, and
12//! RTMP targets. Out of scope: stream maps are rejected, video stream-copy
13//! does not apply (frames are raw, so they are always encoded), and every
14//! other [`Output`](crate::Output) option the writer pipeline could never
15//! honor — audio/subtitle stream configuration, input-referencing metadata —
16//! is rejected at [`open`](crate::VideoWriterBuilder::open) rather than
17//! silently dropped (see `open`'s documentation for the full matrix).
18//!
19//! **Experimental:** this API is new in 0.14 and its surface may still be
20//! refined in minor releases while it settles. The contract below — constant
21//! frame rate, video only — is permanent and not part of what may change.
22//!
23//! ```no_run
24//! use ez_ffmpeg::{Output, VideoWriter};
25//!
26//! # fn render(_i: usize) -> Vec<u8> { vec![0u8; 1920 * 1080 * 4] }
27//! // Pick an encoder explicitly: with a bare "out.mp4" the linked FFmpeg
28//! // build chooses the container default (H.264 when libx264 is compiled
29//! // in, otherwise mpeg4 at a low default bitrate).
30//! let out = Output::from("out.mp4").set_video_codec("mpeg4").set_video_qscale(5);
31//! let mut writer = VideoWriter::builder(1920, 1080).fps(30, 1).open(out)?;
32//! for i in 0..300 {
33//!     writer.write_owned(render(i))?; // one RGBA frame, tightly packed
34//! }
35//! writer.finish()?; // drains the encoder, finalizes the container
36//! # Ok::<(), ez_ffmpeg::error::Error>(())
37//! ```
38//!
39//! # Contract: constant frame rate, video only (permanent)
40//!
41//! This is the type's permanent contract, not a v1 limitation that later
42//! releases will lift:
43//!
44//! - **Constant frame rate.** Every pushed frame advances exactly `den/num`
45//!   seconds; there is no per-frame PTS. `write` takes `&mut self` so the
46//!   total frame order is fixed at compile time.
47//! - **Video only.** The output carries exactly one video stream, encoded
48//!   from the pushed frames; no audio or subtitle stream can be added, and
49//!   [`open`](crate::VideoWriterBuilder::open) rejects `Output` options that
50//!   would configure one.
51//! - **Tight packing.** A frame is exactly
52//!   [`frame_size`](crate::VideoWriter::frame_size) bytes:
53//!   `av_image_get_buffer_size(pix_fmt, w, h, 1)`, planes concatenated in
54//!   descriptor order with no row padding.
55//!
56//! Variable frame rate / per-frame timestamps and muxed audio+video writing
57//! are explicitly out of scope for `VideoWriter`; they belong to future
58//! sibling constructors/types with their own contracts. Code written against
59//! this type keeps exactly these semantics.
60//!
61//! # How it works
62//!
63//! There is no demuxer and no decoder: the builder assembles a pipeline whose
64//! single filtergraph (`buffersrc → [filter_desc | null] → buffersink`) is fed
65//! directly by a frame-source worker. Pushed frames cross an in-process
66//! bounded channel to that worker, which copies them plane-by-plane into
67//! pooled `AVFrame`s and hands them to the filtergraph — exactly where a
68//! decoder would. End of stream is an explicit in-band marker the worker
69//! enqueues when ingress closes, so frames still buffered inside filters
70//! (e.g. `reverse`) are flushed with correct tail timing. Teardown order is
71//! owned by [`VideoWriter`](crate::VideoWriter):
72//!
73//! - [`finish`](crate::VideoWriter::finish) closes ingress (the worker emits
74//!   the EOF marker), drains the encoder, finalizes the container, and
75//!   returns the pipeline's first error. **Files are finalized ONLY by
76//!   `finish()`** — it is the one graceful path.
77//! - [`abort`](crate::VideoWriter::abort) discards the export: it closes
78//!   ingress, then hard-aborts the scheduler without draining what filters,
79//!   the encoder, or an embedded generator could still produce. The partial
80//!   output is not guaranteed playable.
81//! - [`Drop`] is an **abort, not a finish**: dropping a writer tears the run
82//!   down in bounded time (bounded by the workers' status polls, never by
83//!   how much data the graph could still emit) and does NOT finalize the
84//!   file — the same contract as the frame-export iterators. A graph built
85//!   to outlive the pushed stream (an `overlay` without `shortest=1`, a
86//!   `concat` onto an unbounded generator) would make a draining drop block
87//!   indefinitely; aborting keeps drop bounded. Call `finish()` when the
88//!   output matters. That bound does not reach a user write/seek callback
89//!   (`Output::new_by_write_callback` / `set_seek_callback`): it runs
90//!   synchronously on the mux worker with no status check, so a callback
91//!   that never returns holds up teardown just as it would under
92//!   `finish()`.
93//!
94//! # Memory
95//!
96//! Only the **ingress** queue is bounded, by frame count: `queue_capacity`
97//! frames (an exact `frame_size` copy per slot with
98//! [`write`](crate::VideoWriter::write); the caller's `Vec` as provided with
99//! [`write_owned`](crate::VideoWriter::write_owned)), plus a bounded handful
100//! of in-flight frames in the internal channels.
101//! Filters that buffer (`reverse`, `tpad`, …), the codec's lookahead, and
102//! output I/O can each hold further data with no global limit — exactly as
103//! they do in any other job of this crate.
104//!
105//! # FFmpeg versions
106//!
107//! Works on every FFmpeg release this crate supports (7.1–8.x); it opens no
108//! input format context, so no version-specific probing behavior applies.
109
110use std::ffi::CString;
111use std::sync::atomic::{AtomicUsize, Ordering};
112use std::sync::Arc;
113use std::time::Duration;
114
115use crossbeam_channel::{SendTimeoutError, Sender};
116use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_NONE;
117use ffmpeg_sys_next::{
118    av_get_pix_fmt, av_image_get_buffer_size, av_pix_fmt_desc_get, AVPixelFormat,
119    AV_PIX_FMT_FLAG_HWACCEL,
120};
121
122use crate::core::context::ffmpeg_context::build_writer_context;
123use crate::core::context::frame_source::FrameSourceParams;
124use crate::core::context::output::Output;
125use crate::core::scheduler::ffmpeg_scheduler::{is_stopping, FfmpegScheduler, Running};
126use crate::error::OpenOutputError;
127
128/// Ingress memory budget used to derive the default queue depth.
129const QUEUE_BUDGET_BYTES: usize = 64 * 1024 * 1024;
130
131/// How long `write` parks on a full queue before re-checking the pipeline
132/// status. Backpressure, not a busy loop: the crossbeam fast path returns
133/// immediately when there is room.
134const SEND_POLL: Duration = Duration::from_millis(100);
135
136/// Errors raised while validating a [`VideoWriterBuilder`] or opening its
137/// pipeline. Reachable through [`crate::error::Error::Writer`]; it is exported
138/// from [`crate::error`] rather than the crate root to keep the root surface to
139/// the settled writer types.
140#[non_exhaustive]
141#[derive(Debug, thiserror::Error)]
142pub enum WriterError {
143    /// Width or height is zero or exceeds `i32::MAX`.
144    #[error("invalid dimensions {width}x{height}")]
145    InvalidDimensions { width: u32, height: u32 },
146
147    /// The pixel-format name is not known to `av_get_pix_fmt`.
148    #[error("unknown pixel format '{0}'")]
149    UnknownPixelFormat(String),
150
151    /// A hardware pixel format (e.g. `cuda`, `vaapi`) cannot be filled from a
152    /// CPU byte buffer.
153    #[error("hardware pixel format '{0}' cannot be pushed from CPU memory")]
154    HardwarePixelFormat(String),
155
156    /// `fps(num, den)` had a non-positive component.
157    #[error("invalid fps {num}/{den}: both must be positive")]
158    InvalidFps { num: i32, den: i32 },
159
160    /// `queue_capacity(0)` was requested.
161    #[error("queue_capacity must be >= 1")]
162    ZeroQueueCapacity,
163
164    /// The built pipeline consumes no video from the pushed frames (a
165    /// `disable_video()` output). Without this check `write` would block
166    /// forever against a live-but-unconsumed receiver.
167    #[error("output consumes no video stream from the pushed frames")]
168    NoVideoDestination,
169
170    /// The `filter_desc` graph does not consume exactly one video input and
171    /// produce exactly one video output. A second input pad would have no
172    /// producer (the pipeline would buffer forever waiting for it) and a
173    /// second output pad would have no destination.
174    #[error(
175        "filter_desc must have exactly one video input pad and one video \
176         output pad; found {input_pads} input pad(s) ({video_input_pads} \
177         video) and {output_pads} output pad(s) ({video_output_pads} video)"
178    )]
179    FilterShape {
180        input_pads: usize,
181        video_input_pads: usize,
182        output_pads: usize,
183        video_output_pads: usize,
184    },
185
186    /// Both [`VideoWriterBuilder::filter_desc`] and the opened `Output`'s
187    /// `set_video_filter` were configured. The writer runs exactly one
188    /// filter chain between the pushed frames and the encoder, and guessing
189    /// which of the two the caller meant would silently ignore the other —
190    /// configure the chain in exactly one place.
191    #[error(
192        "both VideoWriterBuilder::filter_desc and Output::set_video_filter are set; \
193         configure the writer's filter chain in exactly one place"
194    )]
195    ConflictingFilterDescriptions,
196
197    /// The `filter_desc` parses into more than one disconnected filter
198    /// component (e.g. `"nullsink;color=..."`). The pushed frames would feed
199    /// one part while an unrelated part feeds (or starves) the encoder — an
200    /// unbounded side source could even keep the job from ever finishing.
201    #[error("filter_desc must be a single connected graph; found {components} disconnected parts")]
202    DisconnectedFilterGraph { components: usize },
203
204    /// The `filter_desc` is connected, but no directed path leads from its
205    /// input pad to its output pad (e.g.
206    /// `"color,split[out][aux];[aux][in]overlay,nullsink"`): the pushed
207    /// frames drain into a sink while an unrelated branch feeds the encoder,
208    /// so they could never influence the encoded output — and an unbounded
209    /// side source would keep the job from ever finishing.
210    ///
211    /// This check is STRUCTURAL: it follows the links between filters, and
212    /// inside each filter every input pad is assumed to influence every
213    /// output pad. Filter routing is not pruned by the applied options,
214    /// because libavfilter routing is not static — a selector's `map`
215    /// (`streamselect`) can be rewritten mid-stream by `sendcmd` or the
216    /// send-command API, and ffmpeg itself accepts and runs descriptions
217    /// whose current selection drops the pushed stream. A description that
218    /// discards the pushed frames at runtime (an unselected `streamselect`
219    /// input, a multi-stream `concat` steering them into a sink leg, ...)
220    /// therefore passes this gate and runs as declared, exactly like the
221    /// CLI; what cannot pass is a graph where no wiring could ever carry the
222    /// pushed frames toward the encoder.
223    #[error(
224        "filter_desc has no directed path from its input pad to its output \
225         pad; the pushed frames could not influence the encoded output"
226    )]
227    UnreachableFilterOutput,
228
229    /// The [`Output`] carries stream maps (`add_stream_map` /
230    /// `add_stream_map_with_copy`). The writer's single video stream is the
231    /// only stream there is, so maps have nothing to select; rejecting them
232    /// beats silently ignoring them.
233    #[error("stream maps are not supported by VideoWriter; the pushed frames are the only stream")]
234    StreamMapsUnsupported,
235
236    /// The [`Output`] carries an option whose effect would require an audio
237    /// stream, a subtitle stream, or an input file — none of which a writer
238    /// job can have: its output is exactly one video stream encoded from the
239    /// pushed frames, and it opens no inputs. The shared pipeline machinery
240    /// would silently drop such configuration (an audio codec with no audio
241    /// stream is never consulted; a metadata mapping referencing input 0
242    /// logs a warning and continues), so the writer rejects it at
243    /// [`open`](crate::VideoWriterBuilder::open) instead — a
244    /// `set_audio_codec("aac")` caller learns immediately that no audio
245    /// would be written, rather than shipping a silent file. The full
246    /// honored/rejected matrix is documented on `open`.
247    #[error("Output option {option} is not supported by VideoWriter: {reason}")]
248    UnsupportedOutputOption {
249        /// The offending setter as spelled on [`Output`]
250        /// (e.g. `"set_audio_codec"`).
251        option: &'static str,
252        /// The missing capability that makes the option unsatisfiable.
253        reason: &'static str,
254    },
255}
256
257/// Errors returned by [`VideoWriter::write`] / [`VideoWriter::write_owned`]
258/// (the latter wraps them in [`OwnedPushError`], which also carries the
259/// caller's frame back).
260#[non_exhaustive]
261#[derive(Debug, thiserror::Error)]
262pub enum PushError {
263    /// The frame was not exactly [`VideoWriter::frame_size`] bytes.
264    #[error("frame has {got} bytes, expected exactly {expected} (tightly packed)")]
265    InvalidSize { expected: usize, got: usize },
266
267    /// The pipeline stopped accepting frames (a worker failed, an [`Output`]
268    /// limit such as `set_max_video_frames` completed the job early, or
269    /// teardown began). Call [`VideoWriter::finish`] for the authoritative
270    /// result: the underlying error if one occurred, or `Ok` when the
271    /// pipeline closed after completing normally.
272    #[error("pipeline closed; call finish() to retrieve the pipeline result")]
273    PipelineClosed,
274}
275
276/// Error returned by [`VideoWriter::write_owned`]: the push failure plus the
277/// caller's frame, handed back so the allocation is never lost on an error
278/// path — the `std::sync::mpsc` / crossbeam `SendError` convention. Every
279/// error exit of `write_owned` (size validation, a closed pipeline observed
280/// before, during, or after the queue wait) returns the exact `Vec` that was
281/// passed in: same allocation, length, and capacity.
282///
283/// For error reporting the type is transparent: `Display` and
284/// [`source`](std::error::Error::source) delegate to the inner [`PushError`].
285/// The `Debug` form prints the frame's length, never its bytes. Converting
286/// into [`PushError`] or [`crate::error::Error`] (which is what `?` does in a
287/// function returning [`crate::error::Result`]) DROPS the frame; destructure
288/// with [`into_frame`](Self::into_frame) / [`into_parts`](Self::into_parts)
289/// first when the buffer should be reused.
290///
291/// ```no_run
292/// use ez_ffmpeg::{Output, VideoWriter};
293///
294/// let out = Output::from("out.mp4").set_video_codec("mpeg4");
295/// let mut writer = VideoWriter::builder(64, 48).open(out)?;
296/// let mut frame = vec![0u8; writer.frame_size()];
297/// loop {
298///     // render into `frame` ...
299///     match writer.write_owned(frame) {
300///         Ok(()) => frame = vec![0u8; writer.frame_size()],
301///         Err(rejected) => {
302///             // The buffer comes back; nothing was lost.
303///             let (returned, error) = rejected.into_parts();
304///             frame = returned;
305///             eprintln!("push failed: {error} ({} bytes recovered)", frame.len());
306///             break;
307///         }
308///     }
309/// }
310/// writer.finish()?;
311/// # Ok::<(), ez_ffmpeg::error::Error>(())
312/// ```
313pub struct OwnedPushError {
314    frame: Vec<u8>,
315    error: PushError,
316}
317
318impl OwnedPushError {
319    /// Why the push was rejected.
320    pub fn error(&self) -> &PushError {
321        &self.error
322    }
323
324    /// Recovers the frame exactly as handed to
325    /// [`write_owned`](VideoWriter::write_owned): the same allocation, length,
326    /// and capacity.
327    pub fn into_frame(self) -> Vec<u8> {
328        self.frame
329    }
330
331    /// Splits into the recovered frame and the failure cause.
332    pub fn into_parts(self) -> (Vec<u8>, PushError) {
333        (self.frame, self.error)
334    }
335}
336
337impl std::fmt::Debug for OwnedPushError {
338    /// Manual impl: a frame is often megabytes of pixel data, so `Debug`
339    /// reports its length instead of deriving a full byte dump into panic
340    /// messages and logs.
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("OwnedPushError")
343            .field("error", &self.error)
344            .field("frame_len", &self.frame.len())
345            .finish()
346    }
347}
348
349impl std::fmt::Display for OwnedPushError {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        // Transparent: the cause already says everything; the frame payload is
352        // a recovery affordance, not part of the failure description.
353        std::fmt::Display::fmt(&self.error, f)
354    }
355}
356
357impl std::error::Error for OwnedPushError {
358    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
359        // Transparent over the inner PushError (whose variants are leaves), so
360        // chain printers do not report the same message twice.
361        std::error::Error::source(&self.error)
362    }
363}
364
365/// Drops the recovered frame and keeps the cause — the explicit escape hatch
366/// for callers that do not want the buffer back.
367impl From<OwnedPushError> for PushError {
368    fn from(rejected: OwnedPushError) -> Self {
369        rejected.error
370    }
371}
372
373/// Drops the recovered frame and wraps the cause as
374/// [`crate::error::Error::Push`], so `?` composes in functions returning
375/// [`crate::error::Result`]. Destructure the error first when the buffer
376/// should be reused.
377impl From<OwnedPushError> for crate::error::Error {
378    fn from(rejected: OwnedPushError) -> Self {
379        crate::error::Error::Push(rejected.error)
380    }
381}
382
383/// Pushes raw video frames from Rust code into a full FFmpeg pipeline. See the
384/// [module documentation](self) for the frame contract and teardown semantics.
385/// Output files are finalized ONLY by [`finish`](Self::finish); dropping the
386/// writer aborts the run and leaves the output unfinalized.
387///
388/// **Experimental:** new in 0.14; the surface may still be refined. The
389/// constant-frame-rate, video-only contract is permanent (see the module
390/// docs).
391pub struct VideoWriter {
392    /// `None` once ingress is closed by `finish`/`abort`/`Drop`.
393    sender: Option<Sender<Vec<u8>>>,
394    /// `None` once consumed by `finish`/`abort`, so `Drop` is a no-op afterward.
395    scheduler: Option<FfmpegScheduler<Running>>,
396    frame_size: usize,
397    /// The scheduler status atomic, polled by `write` to fail fast when the
398    /// pipeline is stopping rather than blocking on a full queue forever.
399    status: Arc<AtomicUsize>,
400}
401
402// Send: a writer can move to a dedicated producer thread. Not Sync: `write`
403// takes `&mut self`, which already forbids concurrent producers.
404impl VideoWriter {
405    /// Starts a builder. Width and height are positional so they cannot be
406    /// forgotten.
407    pub fn builder(width: u32, height: u32) -> VideoWriterBuilder {
408        VideoWriterBuilder::new(width, height)
409    }
410
411    /// Exact number of bytes one frame must contain:
412    /// `av_image_get_buffer_size(pix_fmt, width, height, 1)` (tight packing).
413    pub fn frame_size(&self) -> usize {
414        self.frame_size
415    }
416
417    /// Pushes one frame, copying the borrowed slice into an owned buffer.
418    /// Blocks while the internal queue is full (backpressure).
419    pub fn write(&mut self, frame: &[u8]) -> Result<(), PushError> {
420        if frame.len() != self.frame_size {
421            return Err(PushError::InvalidSize {
422                expected: self.frame_size,
423                got: frame.len(),
424            });
425        }
426        // The rejected buffer is the writer's own copy, not the caller's, so
427        // dropping it here loses nothing the caller could reuse.
428        self.enqueue(frame.to_vec()).map_err(PushError::from)
429    }
430
431    /// Pushes one owned frame; the `Vec` is moved into the pipeline, saving
432    /// the borrow-copy that [`write`](Self::write) performs. The pipeline
433    /// still copies the bytes once, plane-by-plane, into an aligned `AVFrame`
434    /// on the worker thread. `frame.len()` must equal
435    /// [`frame_size`](Self::frame_size); the `Vec` is queued as-is, so any
436    /// spare `capacity` beyond its length stays allocated while it waits.
437    ///
438    /// # Errors
439    ///
440    /// Every error path hands the frame back inside [`OwnedPushError`] (the
441    /// `SendError` convention): the exact allocation the caller passed in,
442    /// recoverable via [`OwnedPushError::into_frame`] /
443    /// [`OwnedPushError::into_parts`] for reuse. This covers the size check
444    /// and a pipeline observed closed before, during, or after the queue
445    /// wait. Note that `?` in a function returning [`crate::error::Result`]
446    /// converts away the payload — destructure first when the buffer matters.
447    pub fn write_owned(&mut self, frame: Vec<u8>) -> Result<(), OwnedPushError> {
448        if frame.len() != self.frame_size {
449            let error = PushError::InvalidSize {
450                expected: self.frame_size,
451                got: frame.len(),
452            };
453            return Err(OwnedPushError { frame, error });
454        }
455        self.enqueue(frame)
456    }
457
458    /// Queues one validated frame. Total on payload custody: every `Err`
459    /// carries the frame back out, so `write_owned` can return the caller's
460    /// allocation from any failure (`write` drops it — the copy is ours).
461    fn enqueue(&mut self, frame: Vec<u8>) -> Result<(), OwnedPushError> {
462        // Check the terminal state before sending: probing it only after a send
463        // timeout could still admit one frame in the window between the
464        // terminal publish and the queue draining.
465        if is_stopping(self.status.load(Ordering::Acquire)) {
466            return Err(OwnedPushError {
467                frame,
468                error: PushError::PipelineClosed,
469            });
470        }
471        let sender = match &self.sender {
472            Some(sender) => sender,
473            None => {
474                return Err(OwnedPushError {
475                    frame,
476                    error: PushError::PipelineClosed,
477                })
478            }
479        };
480        let mut msg = frame;
481        loop {
482            match sender.send_timeout(msg, SEND_POLL) {
483                Ok(()) => return Ok(()),
484                Err(SendTimeoutError::Timeout(returned)) => {
485                    // Full queue = backpressure. Re-check status so a pipeline
486                    // that dies while we are parked wakes us instead of hanging.
487                    if is_stopping(self.status.load(Ordering::Acquire)) {
488                        return Err(OwnedPushError {
489                            frame: returned,
490                            error: PushError::PipelineClosed,
491                        });
492                    }
493                    msg = returned;
494                }
495                Err(SendTimeoutError::Disconnected(returned)) => {
496                    return Err(OwnedPushError {
497                        frame: returned,
498                        error: PushError::PipelineClosed,
499                    })
500                }
501            }
502        }
503    }
504
505    /// Closes ingress (the frame source emits its end-of-stream marker),
506    /// drains the encoder, finalizes the container, and returns the first
507    /// authoritative pipeline error. **This is the only graceful path: a
508    /// writer that is dropped instead is aborted and its output is not
509    /// finalized.** A `write` that returned [`PushError::PipelineClosed`]
510    /// resolves here, either to the real cause or to `Ok` when the pipeline
511    /// closed after completing normally (e.g. an [`Output`] frame limit was
512    /// reached).
513    ///
514    /// `finish` waits for the graph to drain, so a
515    /// [`filter_desc`](VideoWriterBuilder::filter_desc) built to outlive the
516    /// pushed stream (an `overlay` without `shortest=1`, a `concat` onto an
517    /// unbounded generator) keeps it blocked until the generator ends — the
518    /// declared semantics, not a malfunction. Drop such a job (or call
519    /// [`abort`](Self::abort)) to discard it in bounded time instead — a
520    /// bound against remaining graph data; a user write/seek callback that
521    /// never returns holds `finish` and that teardown alike (see [`Drop`]).
522    pub fn finish(mut self) -> crate::error::Result<()> {
523        self.sender = None; // drop the sender → the frame source observes EOF
524        match self.scheduler.take() {
525            Some(scheduler) => scheduler.wait(),
526            None => Ok(()),
527        }
528    }
529
530    /// Discards the export: closes ingress, then hard-aborts the scheduler
531    /// and joins its workers. For "user cancelled" flows where the output is
532    /// not wanted. Equivalent to dropping the writer — this method only makes
533    /// the cancellation read as intent instead of a scope end.
534    ///
535    /// The join is bounded by the workers' status polls, not by remaining
536    /// data, but the abort may skip the encoder flush / muxer trailer, so the
537    /// partial file is not guaranteed playable. Use [`finish`](Self::finish)
538    /// to finalize. The one thing the join cannot bound is a user write/seek
539    /// callback: it runs synchronously on the mux worker with no status
540    /// check, so a callback that never returns blocks the join exactly as it
541    /// would block `finish()`.
542    pub fn abort(self) {
543        // Drop IS the abort teardown; one implementation lives there.
544        drop(self);
545    }
546}
547
548impl Drop for VideoWriter {
549    /// Aborts the run — it does NOT finish it. Ingress is closed first (so
550    /// the frame-source worker observes the disconnect), then the scheduler
551    /// is hard-aborted and joined: bounded by the workers' status polls,
552    /// never by how much data filters, the encoder, or an embedded generator
553    /// could still produce — the same contract as dropping a frame-export
554    /// iterator. The output is NOT finalized (no encoder drain, no muxer
555    /// trailer); [`finish`](VideoWriter::finish) is the only path that
556    /// finalizes. After `finish`/`abort` consumed the scheduler this is a
557    /// no-op. That bound does not reach a user write/seek callback, which
558    /// runs synchronously on the mux worker with no status check and, if it
559    /// never returns, holds up this join exactly as it would `finish()`.
560    fn drop(&mut self) {
561        self.sender = None;
562        if let Some(scheduler) = self.scheduler.take() {
563            scheduler.abort();
564        }
565    }
566}
567
568/// Builder for a [`VideoWriter`]. Required parameters (width, height) are
569/// positional; everything else has a default.
570pub struct VideoWriterBuilder {
571    width: u32,
572    height: u32,
573    pixel_format: String,
574    fps_num: i32,
575    fps_den: i32,
576    queue_capacity: Option<usize>,
577    filter_desc: Option<String>,
578}
579
580impl VideoWriterBuilder {
581    fn new(width: u32, height: u32) -> Self {
582        Self {
583            width,
584            height,
585            pixel_format: "rgba".to_string(),
586            fps_num: 30,
587            fps_den: 1,
588            queue_capacity: None,
589            filter_desc: None,
590        }
591    }
592
593    /// Any non-hardware `AVPixelFormat` name (`av_get_pix_fmt`). Default:
594    /// `"rgba"`. Frames are tightly packed, planes concatenated in descriptor
595    /// order (e.g. `yuv420p` = Y then U then V).
596    pub fn pixel_format(mut self, fmt: impl Into<String>) -> Self {
597        self.pixel_format = fmt.into();
598        self
599    }
600
601    /// Frame rate as `num/den`. Default `(30, 1)`; NTSC rates like
602    /// `fps(30000, 1001)` are supported. Every written frame advances exactly
603    /// `den/num` seconds.
604    pub fn fps(mut self, num: i32, den: i32) -> Self {
605        self.fps_num = num;
606        self.fps_den = den;
607        self
608    }
609
610    /// Queue depth in frames. Default: `max(1, min(4, 64 MiB / frame_size))`, so
611    /// large frames do not silently reserve a lot of memory. The queue is
612    /// count-bounded: with [`write`](VideoWriter::write) each slot holds an
613    /// exact `frame_size` copy (`capacity × frame_size` bytes total); with
614    /// [`write_owned`](VideoWriter::write_owned) each slot holds the caller's
615    /// `Vec` as provided, including any spare capacity it carries.
616    pub fn queue_capacity(mut self, frames: usize) -> Self {
617        self.queue_capacity = Some(frames);
618        self
619    }
620
621    /// Optional FFmpeg filter chain between the pushed frames and the encoder,
622    /// e.g. `"hue=s=0"` or `"pad=ceil(iw/2)*2:ceil(ih/2)*2"` (an odd-size
623    /// remedy). Must be a single connected graph
624    /// ([`WriterError::DisconnectedFilterGraph`] otherwise) consuming exactly
625    /// one video input and producing exactly one video output
626    /// ([`WriterError::FilterShape`] otherwise), with the output downstream
627    /// of the input ([`WriterError::UnreachableFilterOutput`] otherwise).
628    ///
629    /// Generator filters embedded in the graph (`color`, `testsrc`, …) are
630    /// allowed as long as the pushed frames still reach the output — e.g.
631    /// compositing over a generated background with
632    /// `"color=...[bg];[in][bg]overlay=shortest=1"`. The same rules as the
633    /// FFmpeg CLI apply: a graph built to outlive the pushed stream (an
634    /// `overlay` without `shortest=1`, a `concat` onto an unbounded
635    /// generator) keeps running after [`finish`](VideoWriter::finish) closes
636    /// the input, until the generator ends or the job is aborted (dropping
637    /// the [`VideoWriter`] aborts it) — that is the semantics asked for, not
638    /// a writer malfunction.
639    ///
640    /// The validation is structural (see
641    /// [`WriterError::UnreachableFilterOutput`]): the pushed frames must be
642    /// wired into the flow that feeds the output, but a filter that may
643    /// discard them at runtime is accepted — a `streamselect` whose current
644    /// `map` selects another input still passes, since that map is
645    /// commandable mid-stream (`sendcmd`), and a multi-stream `concat`
646    /// steering the pushed stream into a sink leg runs exactly as declared,
647    /// like both would in the CLI.
648    ///
649    /// The opened `Output`'s `set_video_filter` supplies the same chain from
650    /// the output side and is honored when this builder-level description is
651    /// absent; setting BOTH fails with
652    /// [`WriterError::ConflictingFilterDescriptions`].
653    pub fn filter_desc(mut self, desc: impl Into<String>) -> Self {
654        self.filter_desc = Some(desc.into());
655        self
656    }
657
658    /// Builds the pipeline and starts it, returning without waiting for the
659    /// first frame.
660    ///
661    /// # `Output` option matrix
662    ///
663    /// The writer builds a job with **no inputs** and **exactly one video
664    /// stream** encoded from the pushed frames, so every [`Output`] option is
665    /// classified: it is either honored by that single-stream pipeline or
666    /// rejected here with a typed error. Nothing is silently ignored, and new
667    /// options are classified the same way when they appear — rejected until
668    /// the writer can genuinely honor them.
669    ///
670    /// **Honored** (the video stream and its container):
671    /// - target: a URL/path, a write callback (`new_by_write_callback`), or a
672    ///   packet sink (subject to the packet sink's own validation); a write
673    ///   callback also honors `set_seek_callback` and `set_io_buffer_size` —
674    ///   with a URL/path target both are inert, exactly as their own
675    ///   `Output` docs state for every job in this crate, not just the
676    ///   writer
677    /// - container: `set_format`, `set_format_opt(s)`, `add_metadata` /
678    ///   `add_metadata_map` / `remove_metadata` / `clear_all_metadata`,
679    ///   `add_stream_metadata`, `add_attachment(_with_mimetype)`,
680    ///   `set_max_muxing_queue_size`, `set_muxing_queue_data_threshold`
681    /// - video encoding: `set_video_codec` (except `"copy"`, rejected: pushed
682    ///   frames must be encoded), `set_video_codec_opt(s)` /
683    ///   `set_video_bitrate`, `set_video_qscale`, `set_pix_fmt`,
684    ///   `set_video_bsf`, `set_force_key_frames`, `set_bits_per_raw_sample`,
685    ///   `set_framerate` / `set_framerate_max` / `set_vsync_method`
686    ///   (encoder-side resampling of the pushed CFR stream, exactly as in any
687    ///   other job), `set_max_video_frames`, `set_start_time_us` /
688    ///   `set_recording_time_us` / `set_stop_time_us`, `set_sws_opts`,
689    ///   video-typed frame pipelines (wgpu…), `set_video_filter` (used
690    ///   when no builder-level [`filter_desc`](Self::filter_desc) is set;
691    ///   both at once is [`WriterError::ConflictingFilterDescriptions`]),
692    ///   and `set_video_codec_tag` (FFmpeg `-tag:v`)
693    /// - vacuously satisfied no-ops: `disable_audio` / `disable_subtitle` /
694    ///   `disable_data` (those streams never exist),
695    ///   `disable_auto_copy_metadata` (there are no inputs to copy from),
696    ///   `set_shortest` / `set_shortest_buf_duration_us` (a single stream has
697    ///   nothing to be cut against, FFmpeg parity), and an
698    ///   `add_stream_metadata` specifier that matches no stream
699    ///
700    /// **Rejected with [`WriterError::UnsupportedOutputOption`]** — options
701    /// whose effect would require an audio stream, a subtitle stream, or an
702    /// input file, none of which a writer job can have:
703    /// - audio: `set_audio_codec`, `set_audio_codec_opt(s)` /
704    ///   `set_audio_bitrate`, `set_audio_qscale`, `set_audio_sample_rate`,
705    ///   `set_audio_channels`, `set_audio_sample_fmt`, `set_audio_bsf`,
706    ///   `set_max_audio_frames`, `set_swr_opts`, `set_audio_filter`,
707    ///   `set_audio_codec_tag`
708    /// - subtitle: `set_subtitle_codec`, `set_subtitle_codec_opt(s)`,
709    ///   `set_subtitle_bsf`, `set_max_subtitle_frames`,
710    ///   `set_subtitle_codec_tag`
711    /// - input-referencing: `map_metadata_from_input`,
712    ///   `add_chapter_metadata` and `add_program_metadata` (chapters and
713    ///   programs only ever come from inputs)
714    ///
715    /// **Rejected with other typed errors** (as before): stream maps
716    /// ([`WriterError::StreamMapsUnsupported`]), `disable_video`
717    /// ([`WriterError::NoVideoDestination`]), `set_video_codec("copy")`, and
718    /// an audio/subtitle-typed frame pipeline (no stream of that type
719    /// exists).
720    ///
721    /// A format that cannot actually carry the video stream is not
722    /// second-guessed at build time: it surfaces as a pipeline error from
723    /// [`finish`](VideoWriter::finish), like any other muxer failure.
724    pub fn open(self, output: impl Into<Output>) -> crate::error::Result<VideoWriter> {
725        if self.width == 0
726            || self.height == 0
727            || self.width > i32::MAX as u32
728            || self.height > i32::MAX as u32
729        {
730            return Err(WriterError::InvalidDimensions {
731                width: self.width,
732                height: self.height,
733            }
734            .into());
735        }
736        if self.fps_num <= 0 || self.fps_den <= 0 {
737            return Err(WriterError::InvalidFps {
738                num: self.fps_num,
739                den: self.fps_den,
740            }
741            .into());
742        }
743        let (pix_fmt, frame_size) =
744            resolve_source_format(&self.pixel_format, self.width, self.height)?;
745        let queue_capacity = match self.queue_capacity {
746            Some(0) => return Err(WriterError::ZeroQueueCapacity.into()),
747            Some(n) => n,
748            None => adaptive_capacity(frame_size),
749        };
750
751        // Reject unusable Output configuration before the build opens the
752        // destination, so a misconfigured open() has no side effects (no file
753        // is created).
754        let output = output.into();
755        validate_writer_output(&output)?;
756
757        let params = FrameSourceParams {
758            width: self.width as i32,
759            height: self.height as i32,
760            pix_fmt,
761            fps_num: self.fps_num,
762            fps_den: self.fps_den,
763        };
764
765        let (ctx, sender) =
766            match build_writer_context(params, queue_capacity, self.filter_desc.as_deref(), output)
767            {
768                Ok(built) => built,
769                // disable_video()/streamless output: the build detects that the
770                // pushed frames have no destination. Translate to the
771                // writer-facing cause.
772                Err(crate::error::Error::OpenOutput(OpenOutputError::NotContainStream)) => {
773                    return Err(WriterError::NoVideoDestination.into());
774                }
775                Err(e) => return Err(e),
776            };
777
778        // Clone the status before the context moves into the scheduler; new()
779        // clones the same Arc internally, so no accessor is needed.
780        let status = ctx.scheduler_status.clone();
781        let scheduler = FfmpegScheduler::new(ctx);
782        let scheduler = scheduler.start()?;
783
784        Ok(VideoWriter {
785            sender: Some(sender),
786            scheduler: Some(scheduler),
787            frame_size,
788            status,
789        })
790    }
791}
792
793/// Default queue depth: cap the ingress buffer at `QUEUE_BUDGET_BYTES` while
794/// keeping at least one and at most four frames. `frame_size >= 1` is
795/// guaranteed by the caller, so the division never traps.
796fn adaptive_capacity(frame_size: usize) -> usize {
797    (QUEUE_BUDGET_BYTES / frame_size).clamp(1, 4)
798}
799
800/// Rejects [`Output`] configuration the writer pipeline could never honor.
801///
802/// The writer builds a job with zero inputs and exactly one video stream, so
803/// options addressed at audio/subtitle streams or at input files have nothing
804/// to act on — the shared pipeline machinery would silently drop them (an
805/// audio codec with no audio stream is never consulted; a metadata mapping
806/// referencing input 0 logs a warning and continues). A typed rejection at
807/// `open()` beats that silent no-op. The writer analog of the packet sink's
808/// `validate_packet_sink_options` gate, and like it, setter USE is what is
809/// rejected: the stored configuration is checked, whatever its value.
810///
811/// Deliberately NOT rejected: vacuously satisfied options (`disable_audio`,
812/// `disable_subtitle`, `disable_data`, `disable_auto_copy_metadata`,
813/// `set_shortest`) ask for something already true of every writer job rather
814/// than for a stream or input that cannot exist; and `add_stream_metadata`,
815/// whose specifier matching is ordinary output behavior (an unmatched
816/// specifier is inert in any job, FFmpeg parity).
817fn validate_writer_output(output: &Output) -> Result<(), WriterError> {
818    const NO_AUDIO_STREAM: &str =
819        "the writer output has no audio stream (video-only is the type's permanent contract)";
820    const NO_SUBTITLE_STREAM: &str =
821        "the writer output has no subtitle stream (video-only is the type's permanent contract)";
822    const NO_INPUT_FILES: &str = "a writer job has no input files to draw metadata from";
823    const NO_CHAPTERS: &str = "a writer job has no inputs, so its output never has chapters";
824    const NO_PROGRAMS: &str = "a writer job has no inputs, so its output never has programs";
825
826    let rejected: &[(&'static str, bool, &'static str)] = &[
827        (
828            "set_audio_codec",
829            output.audio_codec.is_some(),
830            NO_AUDIO_STREAM,
831        ),
832        (
833            "set_audio_codec_opt(s)/set_audio_bitrate",
834            output.audio_codec_opts.is_some(),
835            NO_AUDIO_STREAM,
836        ),
837        (
838            "set_audio_qscale",
839            output.audio_qscale.is_some(),
840            NO_AUDIO_STREAM,
841        ),
842        (
843            "set_audio_sample_rate",
844            output.audio_sample_rate.is_some(),
845            NO_AUDIO_STREAM,
846        ),
847        (
848            "set_audio_channels",
849            output.audio_channels.is_some(),
850            NO_AUDIO_STREAM,
851        ),
852        (
853            "set_audio_sample_fmt",
854            output.audio_sample_fmt.is_some(),
855            NO_AUDIO_STREAM,
856        ),
857        ("set_audio_bsf", output.audio_bsf.is_some(), NO_AUDIO_STREAM),
858        (
859            "set_max_audio_frames",
860            output.max_audio_frames.is_some(),
861            NO_AUDIO_STREAM,
862        ),
863        ("set_swr_opts", output.swr_opts.is_some(), NO_AUDIO_STREAM),
864        (
865            "set_audio_filter",
866            output.audio_filter.is_some(),
867            NO_AUDIO_STREAM,
868        ),
869        (
870            "set_audio_codec_tag",
871            output.audio_codec_tag.is_some(),
872            NO_AUDIO_STREAM,
873        ),
874        (
875            "set_subtitle_codec",
876            output.subtitle_codec.is_some(),
877            NO_SUBTITLE_STREAM,
878        ),
879        (
880            "set_subtitle_codec_opt(s)",
881            output.subtitle_codec_opts.is_some(),
882            NO_SUBTITLE_STREAM,
883        ),
884        (
885            "set_subtitle_bsf",
886            output.subtitle_bsf.is_some(),
887            NO_SUBTITLE_STREAM,
888        ),
889        (
890            "set_max_subtitle_frames",
891            output.max_subtitle_frames.is_some(),
892            NO_SUBTITLE_STREAM,
893        ),
894        (
895            "set_subtitle_codec_tag",
896            output.subtitle_codec_tag.is_some(),
897            NO_SUBTITLE_STREAM,
898        ),
899        (
900            "map_metadata_from_input",
901            !output.metadata_map.is_empty(),
902            NO_INPUT_FILES,
903        ),
904        (
905            "add_chapter_metadata",
906            !output.chapter_metadata.is_empty(),
907            NO_CHAPTERS,
908        ),
909        (
910            "add_program_metadata",
911            !output.program_metadata.is_empty(),
912            NO_PROGRAMS,
913        ),
914    ];
915    for &(option, set, reason) in rejected {
916        if set {
917            return Err(WriterError::UnsupportedOutputOption { option, reason });
918        }
919    }
920    // Stream maps address multi-stream selection the writer does not have:
921    // its single pushed video stream IS the mapping, so maps are rejected
922    // rather than silently ignored — and before the destination is opened.
923    if !output.stream_map_specs.is_empty() || !output.stream_maps.is_empty() {
924        return Err(WriterError::StreamMapsUnsupported);
925    }
926    Ok(())
927}
928
929/// Resolves and validates the pixel format plus the tightly-packed frame size
930/// for it at `width`x`height`. Rejects unknown and hardware formats.
931/// `width`/`height` are already known positive and `<= i32::MAX`.
932fn resolve_source_format(
933    pixel_format: &str,
934    width: u32,
935    height: u32,
936) -> Result<(AVPixelFormat, usize), WriterError> {
937    let cstr = CString::new(pixel_format)
938        .map_err(|_| WriterError::UnknownPixelFormat(pixel_format.to_string()))?;
939    // SAFETY: `cstr` is a valid NUL-terminated string for the duration of the
940    // call; av_get_pix_fmt only reads it.
941    let pix_fmt = unsafe { av_get_pix_fmt(cstr.as_ptr()) };
942    if pix_fmt == AV_PIX_FMT_NONE {
943        return Err(WriterError::UnknownPixelFormat(pixel_format.to_string()));
944    }
945    // SAFETY: pix_fmt is a valid, non-NONE format; av_pix_fmt_desc_get returns a
946    // static descriptor or null.
947    let desc = unsafe { av_pix_fmt_desc_get(pix_fmt) };
948    if !desc.is_null() && unsafe { (*desc).flags } & (AV_PIX_FMT_FLAG_HWACCEL as u64) != 0 {
949        return Err(WriterError::HardwarePixelFormat(pixel_format.to_string()));
950    }
951    // SAFETY: pix_fmt is valid; dimensions are within i32 range.
952    let size = unsafe { av_image_get_buffer_size(pix_fmt, width as i32, height as i32, 1) };
953    if size <= 0 {
954        return Err(WriterError::UnknownPixelFormat(pixel_format.to_string()));
955    }
956    Ok((pix_fmt, size as usize))
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    fn frame_size_of(fmt: &str, w: u32, h: u32) -> Result<usize, WriterError> {
964        resolve_source_format(fmt, w, h).map(|(_, size)| size)
965    }
966
967    #[test]
968    fn frame_size_matches_ffmpeg_for_common_formats() {
969        // rgba/rgb24/gray8: exact, no padding.
970        assert_eq!(frame_size_of("rgba", 64, 48).unwrap(), 64 * 48 * 4);
971        assert_eq!(frame_size_of("rgb24", 64, 48).unwrap(), 64 * 48 * 3);
972        assert_eq!(frame_size_of("gray8", 64, 48).unwrap(), 64 * 48);
973        // yuv420p: Y + U/4 + V/4 = w*h*3/2 for even dimensions.
974        assert_eq!(frame_size_of("yuv420p", 64, 48).unwrap(), 64 * 48 * 3 / 2);
975        // nv12: same total as yuv420p.
976        assert_eq!(frame_size_of("nv12", 64, 48).unwrap(), 64 * 48 * 3 / 2);
977    }
978
979    #[test]
980    fn yuv420p_odd_height_rounds_chroma_up() {
981        // Odd height: chroma planes use ceil(h/2). 64x49: Y=64*49, each chroma
982        // plane = 32*25, total = 64*49 + 2*(32*25).
983        let expected = 64 * 49 + 2 * (32 * 25);
984        assert_eq!(frame_size_of("yuv420p", 64, 49).unwrap(), expected);
985    }
986
987    #[test]
988    fn unknown_pixel_format_is_rejected() {
989        assert!(matches!(
990            frame_size_of("definitely_not_a_format", 16, 16),
991            Err(WriterError::UnknownPixelFormat(_))
992        ));
993    }
994
995    #[test]
996    fn hardware_pixel_format_is_rejected() {
997        // cuda is a hwaccel format when the build knows it; if av_get_pix_fmt
998        // does not recognize it, UnknownPixelFormat is the honest answer. Either
999        // way it must not be accepted as a CPU push format.
1000        assert!(matches!(
1001            frame_size_of("cuda", 16, 16),
1002            Err(WriterError::HardwarePixelFormat(_)) | Err(WriterError::UnknownPixelFormat(_))
1003        ));
1004    }
1005
1006    #[test]
1007    fn adaptive_capacity_scales_with_frame_size() {
1008        // Small frame: capped at 4.
1009        assert_eq!(adaptive_capacity(1), 4);
1010        assert_eq!(adaptive_capacity(1024), 4);
1011        // 1080p rgba (~7.9 MiB): 64 MiB / 7.9 MiB = 8 → clamped to 4.
1012        assert_eq!(adaptive_capacity(1920 * 1080 * 4), 4);
1013        // 4K rgba (~31.6 MiB): floor(64 / 31.6) = 2.
1014        assert_eq!(adaptive_capacity(3840 * 2160 * 4), 2);
1015        // Frame larger than the whole budget: at least 1.
1016        assert_eq!(adaptive_capacity(QUEUE_BUDGET_BYTES * 2), 1);
1017    }
1018
1019    #[test]
1020    fn invalid_dimensions_and_fps_are_rejected_at_open() {
1021        use crate::Output;
1022        let out = || Output::from("/dev/null").set_video_codec("mpeg4");
1023        assert!(matches!(
1024            VideoWriter::builder(0, 48).open(out()),
1025            Err(crate::error::Error::Writer(
1026                WriterError::InvalidDimensions { .. }
1027            ))
1028        ));
1029        assert!(matches!(
1030            VideoWriter::builder(64, 48).fps(0, 1).open(out()),
1031            Err(crate::error::Error::Writer(WriterError::InvalidFps { .. }))
1032        ));
1033        assert!(matches!(
1034            VideoWriter::builder(64, 48).queue_capacity(0).open(out()),
1035            Err(crate::error::Error::Writer(WriterError::ZeroQueueCapacity))
1036        ));
1037        assert!(matches!(
1038            VideoWriter::builder(64, 48)
1039                .pixel_format("nope")
1040                .open(out()),
1041            Err(crate::error::Error::Writer(
1042                WriterError::UnknownPixelFormat(_)
1043            ))
1044        ));
1045    }
1046
1047    /// Compile-time proof that `?` composes across `write`, `write_owned`,
1048    /// and `finish` in a function returning `crate::error::Result` (the
1049    /// `From<PushError>` / `From<OwnedPushError>` / `From<WriterError>`
1050    /// conversions exist). The `write_owned` conversion drops the recovered
1051    /// payload by design.
1052    #[allow(dead_code)]
1053    fn error_composition(
1054        writer: &mut VideoWriter,
1055        frame: &[u8],
1056        owned: Vec<u8>,
1057    ) -> crate::error::Result<()> {
1058        writer.write(frame)?;
1059        writer.write_owned(owned)?;
1060        Ok(())
1061    }
1062
1063    #[test]
1064    fn push_error_display_reports_sizes() {
1065        let e = PushError::InvalidSize {
1066            expected: 12288,
1067            got: 100,
1068        };
1069        assert!(e.to_string().contains("12288"));
1070        assert!(e.to_string().contains("100"));
1071    }
1072
1073    #[test]
1074    fn owned_push_error_is_transparent_and_returns_payload() {
1075        let e = OwnedPushError {
1076            frame: vec![7u8; 4096],
1077            error: PushError::PipelineClosed,
1078        };
1079        // Display is transparent over the cause.
1080        assert_eq!(e.to_string(), PushError::PipelineClosed.to_string());
1081        // Debug reports the length instead of dumping 4096 bytes.
1082        let dbg = format!("{e:?}");
1083        assert!(dbg.contains("frame_len: 4096"), "{dbg}");
1084        assert!(dbg.len() < 200, "Debug must not dump the payload: {dbg}");
1085        // The payload comes back exactly.
1086        let (frame, error) = e.into_parts();
1087        assert_eq!(frame.len(), 4096);
1088        assert!(frame.iter().all(|&b| b == 7));
1089        assert!(matches!(error, PushError::PipelineClosed));
1090    }
1091
1092    #[test]
1093    fn writer_output_validator_classifies_options() {
1094        use crate::Output;
1095        // Honored and vacuously satisfied options pass.
1096        let ok = Output::from("/dev/null")
1097            .set_video_codec("mpeg4")
1098            .set_video_qscale(5)
1099            .disable_audio()
1100            .disable_subtitle()
1101            .disable_data()
1102            .disable_auto_copy_metadata()
1103            .set_shortest(true);
1104        assert!(validate_writer_output(&ok).is_ok());
1105        // A rejected option surfaces with its setter name; the exhaustive
1106        // per-option matrix runs in tests/video_writer.rs.
1107        assert!(matches!(
1108            validate_writer_output(&Output::from("/dev/null").set_audio_codec("aac")),
1109            Err(WriterError::UnsupportedOutputOption {
1110                option: "set_audio_codec",
1111                ..
1112            })
1113        ));
1114    }
1115
1116    /// Teardown liveness with the producer still alive: the frame-source
1117    /// worker sits in no wake set, so scheduler teardown must reach it purely
1118    /// through its 100 ms status polls. Drop the running scheduler (or
1119    /// abort()) while the ingress sender is still held and no EOF was ever
1120    /// signalled — the RunningGuard's join must complete anyway. A regression
1121    /// here (e.g. an untimed recv) hangs, so the join runs under a watchdog.
1122    #[test]
1123    fn scheduler_teardown_completes_with_live_ingress_sender() {
1124        use crate::core::context::ffmpeg_context::build_writer_context;
1125        use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_RGBA;
1126
1127        for (label, abort) in [("drop", false), ("abort", true)] {
1128            let out = std::env::temp_dir().join(format!(
1129                "ez_ffmpeg_writer_sender_held_{label}_{}.mp4",
1130                std::process::id()
1131            ));
1132            let params = FrameSourceParams {
1133                width: 64,
1134                height: 48,
1135                pix_fmt: AV_PIX_FMT_RGBA,
1136                fps_num: 30,
1137                fps_den: 1,
1138            };
1139            let (ctx, sender) = build_writer_context(
1140                params,
1141                2,
1142                None,
1143                Output::from(out.to_str().unwrap()).set_video_codec("mpeg4"),
1144            )
1145            .expect("writer context");
1146            let scheduler = FfmpegScheduler::new(ctx).start().expect("start");
1147            // Put the worker mid-stream so it holds a pooled frame path, not
1148            // just an idle recv.
1149            sender
1150                .send(vec![0u8; 64 * 48 * 4])
1151                .expect("worker must be consuming");
1152
1153            let (tx, rx) = std::sync::mpsc::channel();
1154            std::thread::spawn(move || {
1155                if abort {
1156                    scheduler.abort();
1157                } else {
1158                    drop(scheduler);
1159                }
1160                let _ = tx.send(());
1161            });
1162            // Hang-detection watchdog, generous for loaded machines: the
1163            // property is that teardown completes at all, not how fast.
1164            rx.recv_timeout(Duration::from_secs(30))
1165                .unwrap_or_else(|_| panic!("{label}: teardown hung with a live ingress sender"));
1166            drop(sender);
1167        }
1168    }
1169}