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, and video
13//! stream-copy does not apply (frames are raw, so they are always encoded).
14//!
15//! **Experimental:** this API is new in 0.14 and its surface may still be
16//! refined in minor releases while it settles.
17//!
18//! ```no_run
19//! use ez_ffmpeg::{Output, VideoWriter};
20//!
21//! # fn render(_i: usize) -> Vec<u8> { vec![0u8; 1920 * 1080 * 4] }
22//! // Pick an encoder explicitly: with a bare "out.mp4" the linked FFmpeg
23//! // build chooses the container default (H.264 when libx264 is compiled
24//! // in, otherwise mpeg4 at a low default bitrate).
25//! let out = Output::from("out.mp4").set_video_codec("mpeg4").set_video_qscale(5);
26//! let mut writer = VideoWriter::builder(1920, 1080).fps(30, 1).open(out)?;
27//! for i in 0..300 {
28//!     writer.write_owned(render(i))?; // one RGBA frame, tightly packed
29//! }
30//! writer.finish()?; // drains the encoder, finalizes the container
31//! # Ok::<(), ez_ffmpeg::error::Error>(())
32//! ```
33//!
34//! # Scope (v1)
35//!
36//! - **Constant frame rate, video only.** Every pushed frame advances exactly
37//!   `den/num` seconds; there is no per-frame PTS and no audio. `write` takes
38//!   `&mut self` so the total frame order is fixed at compile time.
39//! - **Tight packing.** A frame is exactly
40//!   [`frame_size`](crate::VideoWriter::frame_size) bytes:
41//!   `av_image_get_buffer_size(pix_fmt, w, h, 1)`, planes concatenated in
42//!   descriptor order with no row padding.
43//!
44//! # How it works
45//!
46//! There is no demuxer and no decoder: the builder assembles a pipeline whose
47//! single filtergraph (`buffersrc → [filter_desc | null] → buffersink`) is fed
48//! directly by a frame-source worker. Pushed frames cross an in-process
49//! bounded channel to that worker, which copies them plane-by-plane into
50//! pooled `AVFrame`s and hands them to the filtergraph — exactly where a
51//! decoder would. End of stream is an explicit in-band marker the worker
52//! enqueues when ingress closes, so frames still buffered inside filters
53//! (e.g. `reverse`) are flushed with correct tail timing. Teardown order is
54//! owned by [`VideoWriter`](crate::VideoWriter):
55//!
56//! - [`finish`](crate::VideoWriter::finish) closes ingress (the worker emits
57//!   the EOF marker), then calls `wait()` — the authoritative way to retrieve
58//!   the pipeline's first error.
59//! - [`Drop`] does the same and logs any error; it may block until the encoder
60//!   drains, so prefer `finish()` when you need the result.
61//! - [`abort`](crate::VideoWriter::abort) discards the export (closes ingress,
62//!   then `abort()`s the scheduler); the partial output is not guaranteed
63//!   playable.
64//!
65//! # Memory
66//!
67//! Only the **ingress** queue is bounded, by frame count: `queue_capacity`
68//! frames (an exact `frame_size` copy per slot with
69//! [`write`](crate::VideoWriter::write); the caller's `Vec` as provided with
70//! [`write_owned`](crate::VideoWriter::write_owned)), plus a bounded handful
71//! of in-flight frames in the internal channels.
72//! Filters that buffer (`reverse`, `tpad`, …), the codec's lookahead, and
73//! output I/O can each hold further data with no global limit — exactly as
74//! they do in any other job of this crate.
75//!
76//! # FFmpeg versions
77//!
78//! Works on every FFmpeg release this crate supports (7.1–8.x); it opens no
79//! input format context, so no version-specific probing behavior applies.
80
81use std::ffi::CString;
82use std::sync::atomic::{AtomicUsize, Ordering};
83use std::sync::Arc;
84use std::time::Duration;
85
86use crossbeam_channel::{SendTimeoutError, Sender};
87use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_NONE;
88use ffmpeg_sys_next::{
89    av_get_pix_fmt, av_image_get_buffer_size, av_pix_fmt_desc_get, AVPixelFormat,
90    AV_PIX_FMT_FLAG_HWACCEL,
91};
92
93use crate::core::context::ffmpeg_context::build_writer_context;
94use crate::core::context::frame_source::FrameSourceParams;
95use crate::core::context::output::Output;
96use crate::core::scheduler::ffmpeg_scheduler::{is_stopping, FfmpegScheduler, Running};
97use crate::error::OpenOutputError;
98
99/// Ingress memory budget used to derive the default queue depth.
100const QUEUE_BUDGET_BYTES: usize = 64 * 1024 * 1024;
101
102/// How long `write` parks on a full queue before re-checking the pipeline
103/// status. Backpressure, not a busy loop: the crossbeam fast path returns
104/// immediately when there is room.
105const SEND_POLL: Duration = Duration::from_millis(100);
106
107/// Errors raised while validating a [`VideoWriterBuilder`] or opening its
108/// pipeline. Reachable through [`crate::error::Error::Writer`]; it is exported
109/// from [`crate::error`] rather than the crate root to keep the root surface to
110/// the three settled writer types.
111#[non_exhaustive]
112#[derive(Debug, thiserror::Error)]
113pub enum WriterError {
114    /// Width or height is zero or exceeds `i32::MAX`.
115    #[error("invalid dimensions {width}x{height}")]
116    InvalidDimensions { width: u32, height: u32 },
117
118    /// The pixel-format name is not known to `av_get_pix_fmt`.
119    #[error("unknown pixel format '{0}'")]
120    UnknownPixelFormat(String),
121
122    /// A hardware pixel format (e.g. `cuda`, `vaapi`) cannot be filled from a
123    /// CPU byte buffer.
124    #[error("hardware pixel format '{0}' cannot be pushed from CPU memory")]
125    HardwarePixelFormat(String),
126
127    /// `fps(num, den)` had a non-positive component.
128    #[error("invalid fps {num}/{den}: both must be positive")]
129    InvalidFps { num: i32, den: i32 },
130
131    /// `queue_capacity(0)` was requested.
132    #[error("queue_capacity must be >= 1")]
133    ZeroQueueCapacity,
134
135    /// The built pipeline consumes no video from the pushed frames (a
136    /// `disable_video()` output). Without this check `write` would block
137    /// forever against a live-but-unconsumed receiver.
138    #[error("output consumes no video stream from the pushed frames")]
139    NoVideoDestination,
140
141    /// The `filter_desc` graph does not consume exactly one video input and
142    /// produce exactly one video output. A second input pad would have no
143    /// producer (the pipeline would buffer forever waiting for it) and a
144    /// second output pad would have no destination.
145    #[error(
146        "filter_desc must have exactly one video input pad and one video \
147         output pad; found {input_pads} input pad(s) ({video_input_pads} \
148         video) and {output_pads} output pad(s) ({video_output_pads} video)"
149    )]
150    FilterShape {
151        input_pads: usize,
152        video_input_pads: usize,
153        output_pads: usize,
154        video_output_pads: usize,
155    },
156
157    /// Both [`VideoWriterBuilder::filter_desc`] and the opened `Output`'s
158    /// `set_video_filter` were configured. The writer runs exactly one
159    /// filter chain between the pushed frames and the encoder, and guessing
160    /// which of the two the caller meant would silently ignore the other —
161    /// configure the chain in exactly one place.
162    #[error(
163        "both VideoWriterBuilder::filter_desc and Output::set_video_filter are set; \
164         configure the writer's filter chain in exactly one place"
165    )]
166    ConflictingFilterDescriptions,
167
168    /// The `filter_desc` parses into more than one disconnected filter
169    /// component (e.g. `"nullsink;color=..."`). The pushed frames would feed
170    /// one part while an unrelated part feeds (or starves) the encoder — an
171    /// unbounded side source could even keep the job from ever finishing.
172    #[error("filter_desc must be a single connected graph; found {components} disconnected parts")]
173    DisconnectedFilterGraph { components: usize },
174
175    /// The `filter_desc` is connected, but no directed path leads from its
176    /// input pad to its output pad (e.g.
177    /// `"color,split[out][aux];[aux][in]overlay,nullsink"`): the pushed
178    /// frames drain into a sink while an unrelated branch feeds the encoder,
179    /// so they could never influence the encoded output — and an unbounded
180    /// side source would keep the job from ever finishing.
181    ///
182    /// This check is STRUCTURAL: it follows the links between filters, and
183    /// inside each filter every input pad is assumed to influence every
184    /// output pad. Filter routing is not pruned by the applied options,
185    /// because libavfilter routing is not static — a selector's `map`
186    /// (`streamselect`) can be rewritten mid-stream by `sendcmd` or the
187    /// send-command API, and ffmpeg itself accepts and runs descriptions
188    /// whose current selection drops the pushed stream. A description that
189    /// discards the pushed frames at runtime (an unselected `streamselect`
190    /// input, a multi-stream `concat` steering them into a sink leg, ...)
191    /// therefore passes this gate and runs as declared, exactly like the
192    /// CLI; what cannot pass is a graph where no wiring could ever carry the
193    /// pushed frames toward the encoder.
194    #[error(
195        "filter_desc has no directed path from its input pad to its output \
196         pad; the pushed frames could not influence the encoded output"
197    )]
198    UnreachableFilterOutput,
199
200    /// The [`Output`] carries stream maps (`add_stream_map` /
201    /// `add_stream_map_with_copy`). The writer's single video stream is the
202    /// only stream there is, so maps have nothing to select; rejecting them
203    /// beats silently ignoring them.
204    #[error("stream maps are not supported by VideoWriter; the pushed frames are the only stream")]
205    StreamMapsUnsupported,
206}
207
208/// Errors returned by [`VideoWriter::write`] / [`VideoWriter::write_owned`].
209#[non_exhaustive]
210#[derive(Debug, thiserror::Error)]
211pub enum PushError {
212    /// The frame was not exactly [`VideoWriter::frame_size`] bytes.
213    #[error("frame has {got} bytes, expected exactly {expected} (tightly packed)")]
214    InvalidSize { expected: usize, got: usize },
215
216    /// The pipeline stopped accepting frames (a worker failed, an [`Output`]
217    /// limit such as `set_max_video_frames` completed the job early, or
218    /// teardown began). Call [`VideoWriter::finish`] for the authoritative
219    /// result: the underlying error if one occurred, or `Ok` when the
220    /// pipeline closed after completing normally.
221    #[error("pipeline closed; call finish() to retrieve the pipeline result")]
222    PipelineClosed,
223}
224
225/// Pushes raw video frames from Rust code into a full FFmpeg pipeline. See the
226/// [module documentation](self) for the frame contract and teardown semantics.
227///
228/// **Experimental:** new in 0.14; the surface may still be refined.
229pub struct VideoWriter {
230    /// `None` once ingress is closed by `finish`/`abort`/`Drop`.
231    sender: Option<Sender<Vec<u8>>>,
232    /// `None` once consumed by `finish`/`abort`, so `Drop` is a no-op afterward.
233    scheduler: Option<FfmpegScheduler<Running>>,
234    frame_size: usize,
235    /// The scheduler status atomic, polled by `write` to fail fast when the
236    /// pipeline is stopping rather than blocking on a full queue forever.
237    status: Arc<AtomicUsize>,
238}
239
240// Send: a writer can move to a dedicated producer thread. Not Sync: `write`
241// takes `&mut self`, which already forbids concurrent producers.
242impl VideoWriter {
243    /// Starts a builder. Width and height are positional so they cannot be
244    /// forgotten.
245    pub fn builder(width: u32, height: u32) -> VideoWriterBuilder {
246        VideoWriterBuilder::new(width, height)
247    }
248
249    /// Exact number of bytes one frame must contain:
250    /// `av_image_get_buffer_size(pix_fmt, width, height, 1)` (tight packing).
251    pub fn frame_size(&self) -> usize {
252        self.frame_size
253    }
254
255    /// Pushes one frame, copying the borrowed slice into an owned buffer.
256    /// Blocks while the internal queue is full (backpressure).
257    pub fn write(&mut self, frame: &[u8]) -> Result<(), PushError> {
258        if frame.len() != self.frame_size {
259            return Err(PushError::InvalidSize {
260                expected: self.frame_size,
261                got: frame.len(),
262            });
263        }
264        self.enqueue(frame.to_vec())
265    }
266
267    /// Pushes one owned frame; the `Vec` is moved into the pipeline, saving
268    /// the borrow-copy that [`write`](Self::write) performs. The pipeline
269    /// still copies the bytes once, plane-by-plane, into an aligned `AVFrame`
270    /// on the worker thread. `frame.len()` must equal
271    /// [`frame_size`](Self::frame_size); the `Vec` is queued as-is, so any
272    /// spare `capacity` beyond its length stays allocated while it waits.
273    pub fn write_owned(&mut self, frame: Vec<u8>) -> Result<(), PushError> {
274        if frame.len() != self.frame_size {
275            return Err(PushError::InvalidSize {
276                expected: self.frame_size,
277                got: frame.len(),
278            });
279        }
280        self.enqueue(frame)
281    }
282
283    fn enqueue(&mut self, frame: Vec<u8>) -> Result<(), PushError> {
284        // Check the terminal state before sending: probing it only after a send
285        // timeout could still admit one frame in the window between the
286        // terminal publish and the queue draining.
287        if is_stopping(self.status.load(Ordering::Acquire)) {
288            return Err(PushError::PipelineClosed);
289        }
290        let sender = match &self.sender {
291            Some(sender) => sender,
292            None => return Err(PushError::PipelineClosed),
293        };
294        let mut msg = frame;
295        loop {
296            match sender.send_timeout(msg, SEND_POLL) {
297                Ok(()) => return Ok(()),
298                Err(SendTimeoutError::Timeout(returned)) => {
299                    // Full queue = backpressure. Re-check status so a pipeline
300                    // that dies while we are parked wakes us instead of hanging.
301                    if is_stopping(self.status.load(Ordering::Acquire)) {
302                        return Err(PushError::PipelineClosed);
303                    }
304                    msg = returned;
305                }
306                Err(SendTimeoutError::Disconnected(_)) => return Err(PushError::PipelineClosed),
307            }
308        }
309    }
310
311    /// Closes ingress (the frame source emits its end-of-stream marker),
312    /// drains the encoder, finalizes the container, and returns the first
313    /// authoritative pipeline error. This is the result path — a `write` that
314    /// returned [`PushError::PipelineClosed`] resolves here, either to the
315    /// real cause or to `Ok` when the pipeline closed after completing
316    /// normally (e.g. an [`Output`] frame limit was reached).
317    pub fn finish(mut self) -> crate::error::Result<()> {
318        self.sender = None; // drop the sender → the frame source observes EOF
319        match self.scheduler.take() {
320            Some(scheduler) => scheduler.wait(),
321            None => Ok(()),
322        }
323    }
324
325    /// Discards the export: drops ingress, then hard-aborts the scheduler. For
326    /// "user cancelled" flows where the output is not wanted.
327    ///
328    /// Caveat: `abort()` blocks until every worker releases its slot (no fixed
329    /// time bound) and may skip the encoder flush / muxer trailer, so the
330    /// partial file is not guaranteed playable. Use [`finish`](Self::finish) to
331    /// finalize.
332    pub fn abort(mut self) {
333        self.sender = None;
334        if let Some(scheduler) = self.scheduler.take() {
335            scheduler.abort();
336        }
337    }
338}
339
340impl Drop for VideoWriter {
341    fn drop(&mut self) {
342        // Close ingress first, then wait: the dropped sender is what lets the
343        // frame source emit EOF and exit, so the worker join can complete.
344        self.sender = None;
345        if let Some(scheduler) = self.scheduler.take() {
346            if let Err(e) = scheduler.wait() {
347                log::error!("VideoWriter dropped without finish(); pipeline error: {e}");
348            }
349        }
350    }
351}
352
353/// Builder for a [`VideoWriter`]. Required parameters (width, height) are
354/// positional; everything else has a default.
355pub struct VideoWriterBuilder {
356    width: u32,
357    height: u32,
358    pixel_format: String,
359    fps_num: i32,
360    fps_den: i32,
361    queue_capacity: Option<usize>,
362    filter_desc: Option<String>,
363}
364
365impl VideoWriterBuilder {
366    fn new(width: u32, height: u32) -> Self {
367        Self {
368            width,
369            height,
370            pixel_format: "rgba".to_string(),
371            fps_num: 30,
372            fps_den: 1,
373            queue_capacity: None,
374            filter_desc: None,
375        }
376    }
377
378    /// Any non-hardware `AVPixelFormat` name (`av_get_pix_fmt`). Default:
379    /// `"rgba"`. Frames are tightly packed, planes concatenated in descriptor
380    /// order (e.g. `yuv420p` = Y then U then V).
381    pub fn pixel_format(mut self, fmt: impl Into<String>) -> Self {
382        self.pixel_format = fmt.into();
383        self
384    }
385
386    /// Frame rate as `num/den`. Default `(30, 1)`; NTSC rates like
387    /// `fps(30000, 1001)` are supported. Every written frame advances exactly
388    /// `den/num` seconds.
389    pub fn fps(mut self, num: i32, den: i32) -> Self {
390        self.fps_num = num;
391        self.fps_den = den;
392        self
393    }
394
395    /// Queue depth in frames. Default: `max(1, min(4, 64 MiB / frame_size))`, so
396    /// large frames do not silently reserve a lot of memory. The queue is
397    /// count-bounded: with [`write`](VideoWriter::write) each slot holds an
398    /// exact `frame_size` copy (`capacity × frame_size` bytes total); with
399    /// [`write_owned`](VideoWriter::write_owned) each slot holds the caller's
400    /// `Vec` as provided, including any spare capacity it carries.
401    pub fn queue_capacity(mut self, frames: usize) -> Self {
402        self.queue_capacity = Some(frames);
403        self
404    }
405
406    /// Optional FFmpeg filter chain between the pushed frames and the encoder,
407    /// e.g. `"hue=s=0"` or `"pad=ceil(iw/2)*2:ceil(ih/2)*2"` (an odd-size
408    /// remedy). Must be a single connected graph
409    /// ([`WriterError::DisconnectedFilterGraph`] otherwise) consuming exactly
410    /// one video input and producing exactly one video output
411    /// ([`WriterError::FilterShape`] otherwise), with the output downstream
412    /// of the input ([`WriterError::UnreachableFilterOutput`] otherwise).
413    ///
414    /// Generator filters embedded in the graph (`color`, `testsrc`, …) are
415    /// allowed as long as the pushed frames still reach the output — e.g.
416    /// compositing over a generated background with
417    /// `"color=...[bg];[in][bg]overlay=shortest=1"`. The same rules as the
418    /// FFmpeg CLI apply: a graph built to outlive the pushed stream (an
419    /// `overlay` without `shortest=1`, a `concat` onto an unbounded
420    /// generator) keeps running after [`finish`](VideoWriter::finish) closes
421    /// the input, until the generator ends or the job is aborted — that is
422    /// the semantics asked for, not a writer malfunction.
423    ///
424    /// The validation is structural (see
425    /// [`WriterError::UnreachableFilterOutput`]): the pushed frames must be
426    /// wired into the flow that feeds the output, but a filter that may
427    /// discard them at runtime is accepted — a `streamselect` whose current
428    /// `map` selects another input still passes, since that map is
429    /// commandable mid-stream (`sendcmd`), and a multi-stream `concat`
430    /// steering the pushed stream into a sink leg runs exactly as declared,
431    /// like both would in the CLI.
432    ///
433    /// The opened `Output`'s `set_video_filter` supplies the same chain from
434    /// the output side and is honored when this builder-level description is
435    /// absent; setting BOTH fails with
436    /// [`WriterError::ConflictingFilterDescriptions`].
437    pub fn filter_desc(mut self, desc: impl Into<String>) -> Self {
438        self.filter_desc = Some(desc.into());
439        self
440    }
441
442    /// Builds the pipeline and starts it, returning without waiting for the
443    /// first frame. `output` carries the [`Output`] capabilities that make
444    /// sense for a single pushed video stream: codec, codec options, format,
445    /// format options (`movflags`…), write/seek callbacks, frame pipelines
446    /// (wgpu…), bitrate, qscale, frame limits, and `set_video_filter` (used
447    /// when no builder-level [`filter_desc`](Self::filter_desc) is set;
448    /// both at once is
449    /// [`WriterError::ConflictingFilterDescriptions`]). Stream maps are the
450    /// exception and are rejected ([`WriterError::StreamMapsUnsupported`]) — there is
451    /// only one stream to map. A format that cannot actually carry the video
452    /// stream is not second-guessed at build time: it surfaces as a pipeline
453    /// error from [`finish`](VideoWriter::finish), like any other muxer
454    /// failure.
455    pub fn open(self, output: impl Into<Output>) -> crate::error::Result<VideoWriter> {
456        if self.width == 0
457            || self.height == 0
458            || self.width > i32::MAX as u32
459            || self.height > i32::MAX as u32
460        {
461            return Err(WriterError::InvalidDimensions {
462                width: self.width,
463                height: self.height,
464            }
465            .into());
466        }
467        if self.fps_num <= 0 || self.fps_den <= 0 {
468            return Err(WriterError::InvalidFps {
469                num: self.fps_num,
470                den: self.fps_den,
471            }
472            .into());
473        }
474        let (pix_fmt, frame_size) =
475            resolve_source_format(&self.pixel_format, self.width, self.height)?;
476        let queue_capacity = match self.queue_capacity {
477            Some(0) => return Err(WriterError::ZeroQueueCapacity.into()),
478            Some(n) => n,
479            None => adaptive_capacity(frame_size),
480        };
481
482        let params = FrameSourceParams {
483            width: self.width as i32,
484            height: self.height as i32,
485            pix_fmt,
486            fps_num: self.fps_num,
487            fps_den: self.fps_den,
488        };
489
490        let (ctx, sender) = match build_writer_context(
491            params,
492            queue_capacity,
493            self.filter_desc.as_deref(),
494            output.into(),
495        ) {
496            Ok(built) => built,
497            // disable_video()/streamless output: the build detects that the
498            // pushed frames have no destination. Translate to the
499            // writer-facing cause.
500            Err(crate::error::Error::OpenOutput(OpenOutputError::NotContainStream)) => {
501                return Err(WriterError::NoVideoDestination.into());
502            }
503            Err(e) => return Err(e),
504        };
505
506        // Clone the status before the context moves into the scheduler; new()
507        // clones the same Arc internally, so no accessor is needed.
508        let status = ctx.scheduler_status.clone();
509        let scheduler = FfmpegScheduler::new(ctx);
510        let scheduler = scheduler.start()?;
511
512        Ok(VideoWriter {
513            sender: Some(sender),
514            scheduler: Some(scheduler),
515            frame_size,
516            status,
517        })
518    }
519}
520
521/// Default queue depth: cap the ingress buffer at `QUEUE_BUDGET_BYTES` while
522/// keeping at least one and at most four frames. `frame_size >= 1` is
523/// guaranteed by the caller, so the division never traps.
524fn adaptive_capacity(frame_size: usize) -> usize {
525    (QUEUE_BUDGET_BYTES / frame_size).clamp(1, 4)
526}
527
528/// Resolves and validates the pixel format plus the tightly-packed frame size
529/// for it at `width`x`height`. Rejects unknown and hardware formats.
530/// `width`/`height` are already known positive and `<= i32::MAX`.
531fn resolve_source_format(
532    pixel_format: &str,
533    width: u32,
534    height: u32,
535) -> Result<(AVPixelFormat, usize), WriterError> {
536    let cstr = CString::new(pixel_format)
537        .map_err(|_| WriterError::UnknownPixelFormat(pixel_format.to_string()))?;
538    // SAFETY: `cstr` is a valid NUL-terminated string for the duration of the
539    // call; av_get_pix_fmt only reads it.
540    let pix_fmt = unsafe { av_get_pix_fmt(cstr.as_ptr()) };
541    if pix_fmt == AV_PIX_FMT_NONE {
542        return Err(WriterError::UnknownPixelFormat(pixel_format.to_string()));
543    }
544    // SAFETY: pix_fmt is a valid, non-NONE format; av_pix_fmt_desc_get returns a
545    // static descriptor or null.
546    let desc = unsafe { av_pix_fmt_desc_get(pix_fmt) };
547    if !desc.is_null() && unsafe { (*desc).flags } & (AV_PIX_FMT_FLAG_HWACCEL as u64) != 0 {
548        return Err(WriterError::HardwarePixelFormat(pixel_format.to_string()));
549    }
550    // SAFETY: pix_fmt is valid; dimensions are within i32 range.
551    let size = unsafe { av_image_get_buffer_size(pix_fmt, width as i32, height as i32, 1) };
552    if size <= 0 {
553        return Err(WriterError::UnknownPixelFormat(pixel_format.to_string()));
554    }
555    Ok((pix_fmt, size as usize))
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn frame_size_of(fmt: &str, w: u32, h: u32) -> Result<usize, WriterError> {
563        resolve_source_format(fmt, w, h).map(|(_, size)| size)
564    }
565
566    #[test]
567    fn frame_size_matches_ffmpeg_for_common_formats() {
568        // rgba/rgb24/gray8: exact, no padding.
569        assert_eq!(frame_size_of("rgba", 64, 48).unwrap(), 64 * 48 * 4);
570        assert_eq!(frame_size_of("rgb24", 64, 48).unwrap(), 64 * 48 * 3);
571        assert_eq!(frame_size_of("gray8", 64, 48).unwrap(), 64 * 48);
572        // yuv420p: Y + U/4 + V/4 = w*h*3/2 for even dimensions.
573        assert_eq!(frame_size_of("yuv420p", 64, 48).unwrap(), 64 * 48 * 3 / 2);
574        // nv12: same total as yuv420p.
575        assert_eq!(frame_size_of("nv12", 64, 48).unwrap(), 64 * 48 * 3 / 2);
576    }
577
578    #[test]
579    fn yuv420p_odd_height_rounds_chroma_up() {
580        // Odd height: chroma planes use ceil(h/2). 64x49: Y=64*49, each chroma
581        // plane = 32*25, total = 64*49 + 2*(32*25).
582        let expected = 64 * 49 + 2 * (32 * 25);
583        assert_eq!(frame_size_of("yuv420p", 64, 49).unwrap(), expected);
584    }
585
586    #[test]
587    fn unknown_pixel_format_is_rejected() {
588        assert!(matches!(
589            frame_size_of("definitely_not_a_format", 16, 16),
590            Err(WriterError::UnknownPixelFormat(_))
591        ));
592    }
593
594    #[test]
595    fn hardware_pixel_format_is_rejected() {
596        // cuda is a hwaccel format when the build knows it; if av_get_pix_fmt
597        // does not recognize it, UnknownPixelFormat is the honest answer. Either
598        // way it must not be accepted as a CPU push format.
599        assert!(matches!(
600            frame_size_of("cuda", 16, 16),
601            Err(WriterError::HardwarePixelFormat(_)) | Err(WriterError::UnknownPixelFormat(_))
602        ));
603    }
604
605    #[test]
606    fn adaptive_capacity_scales_with_frame_size() {
607        // Small frame: capped at 4.
608        assert_eq!(adaptive_capacity(1), 4);
609        assert_eq!(adaptive_capacity(1024), 4);
610        // 1080p rgba (~7.9 MiB): 64 MiB / 7.9 MiB = 8 → clamped to 4.
611        assert_eq!(adaptive_capacity(1920 * 1080 * 4), 4);
612        // 4K rgba (~31.6 MiB): floor(64 / 31.6) = 2.
613        assert_eq!(adaptive_capacity(3840 * 2160 * 4), 2);
614        // Frame larger than the whole budget: at least 1.
615        assert_eq!(adaptive_capacity(QUEUE_BUDGET_BYTES * 2), 1);
616    }
617
618    #[test]
619    fn invalid_dimensions_and_fps_are_rejected_at_open() {
620        use crate::Output;
621        let out = || Output::from("/dev/null").set_video_codec("mpeg4");
622        assert!(matches!(
623            VideoWriter::builder(0, 48).open(out()),
624            Err(crate::error::Error::Writer(
625                WriterError::InvalidDimensions { .. }
626            ))
627        ));
628        assert!(matches!(
629            VideoWriter::builder(64, 48).fps(0, 1).open(out()),
630            Err(crate::error::Error::Writer(WriterError::InvalidFps { .. }))
631        ));
632        assert!(matches!(
633            VideoWriter::builder(64, 48).queue_capacity(0).open(out()),
634            Err(crate::error::Error::Writer(WriterError::ZeroQueueCapacity))
635        ));
636        assert!(matches!(
637            VideoWriter::builder(64, 48)
638                .pixel_format("nope")
639                .open(out()),
640            Err(crate::error::Error::Writer(
641                WriterError::UnknownPixelFormat(_)
642            ))
643        ));
644    }
645
646    /// Compile-time proof that `?` composes across `write` and `finish` in a
647    /// function returning `crate::error::Result` (the `From<PushError>` /
648    /// `From<WriterError>` conversions exist).
649    #[allow(dead_code)]
650    fn error_composition(writer: &mut VideoWriter, frame: &[u8]) -> crate::error::Result<()> {
651        writer.write(frame)?;
652        Ok(())
653    }
654
655    #[test]
656    fn push_error_display_reports_sizes() {
657        let e = PushError::InvalidSize {
658            expected: 12288,
659            got: 100,
660        };
661        assert!(e.to_string().contains("12288"));
662        assert!(e.to_string().contains("100"));
663    }
664
665    /// Teardown liveness with the producer still alive: the frame-source
666    /// worker sits in no wake set, so scheduler teardown must reach it purely
667    /// through its 100 ms status polls. Drop the running scheduler (or
668    /// abort()) while the ingress sender is still held and no EOF was ever
669    /// signalled — the RunningGuard's join must complete anyway. A regression
670    /// here (e.g. an untimed recv) hangs, so the join runs under a watchdog.
671    #[test]
672    fn scheduler_teardown_completes_with_live_ingress_sender() {
673        use crate::core::context::ffmpeg_context::build_writer_context;
674        use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_RGBA;
675
676        for (label, abort) in [("drop", false), ("abort", true)] {
677            let out = std::env::temp_dir().join(format!(
678                "ez_ffmpeg_writer_sender_held_{label}_{}.mp4",
679                std::process::id()
680            ));
681            let params = FrameSourceParams {
682                width: 64,
683                height: 48,
684                pix_fmt: AV_PIX_FMT_RGBA,
685                fps_num: 30,
686                fps_den: 1,
687            };
688            let (ctx, sender) = build_writer_context(
689                params,
690                2,
691                None,
692                Output::from(out.to_str().unwrap()).set_video_codec("mpeg4"),
693            )
694            .expect("writer context");
695            let scheduler = FfmpegScheduler::new(ctx).start().expect("start");
696            // Put the worker mid-stream so it holds a pooled frame path, not
697            // just an idle recv.
698            sender
699                .send(vec![0u8; 64 * 48 * 4])
700                .expect("worker must be consuming");
701
702            let (tx, rx) = std::sync::mpsc::channel();
703            std::thread::spawn(move || {
704                if abort {
705                    scheduler.abort();
706                } else {
707                    drop(scheduler);
708                }
709                let _ = tx.send(());
710            });
711            // Hang-detection watchdog, generous for loaded machines: the
712            // property is that teardown completes at all, not how fast.
713            rx.recv_timeout(Duration::from_secs(30))
714                .unwrap_or_else(|_| panic!("{label}: teardown hung with a live ingress sender"));
715            drop(sender);
716        }
717    }
718}