Skip to main content

ez_ffmpeg/core/context/ffmpeg_context/
mod.rs

1use crate::core::context::demuxer::{CopyMuxHandle, Demuxer};
2use crate::core::context::ffmpeg_context_builder::FfmpegContextBuilder;
3use crate::core::context::filter_complex::FilterComplex;
4use crate::core::context::filter_graph::FilterGraph;
5use crate::core::context::input::Input;
6use crate::core::context::input_filter::{InputFilter, IFILTER_FLAG_AUTOROTATE};
7use crate::core::context::muxer::Muxer;
8use crate::core::context::output::{ExpandedStreamMap, Output};
9use crate::core::context::output_filter::{
10    OutputFilter, OFILTER_FLAG_AUDIO_24BIT, OFILTER_FLAG_AUTOSCALE, OFILTER_FLAG_DISABLE_CONVERT,
11};
12use crate::core::context::{frame_alloc, CodecContext};
13use crate::core::metadata::StreamSpecifier;
14use crate::core::scheduler::ffmpeg_scheduler;
15use crate::core::scheduler::ffmpeg_scheduler::{FfmpegScheduler, Initialization};
16#[cfg(not(docsrs))]
17use crate::core::scheduler::filter_task::graph_opts_apply;
18use crate::core::scheduler::input_controller::SchNode;
19use crate::error::Error::{
20    FileSameAsInput, FilterZeroInputs, FilterZeroOutputs, FrameFilterStreamTypeNoMatched,
21    FrameFilterTypeNoMatched, ParseInteger,
22};
23use crate::error::FilterGraphParseError::{
24    InvalidFileIndexInFg, InvalidFilterSpecifier, OutputUnconnected,
25};
26use crate::error::{
27    AllocOutputContextError, FilterGraphOperationError, FilterGraphParseError, FindStreamError,
28    OpenInputError, OpenOutputError,
29};
30use crate::error::{Error, Result};
31use crate::filter::frame_pipeline::FramePipeline;
32use crate::hwaccel::{hw_device_for_filter, init_filter_hw_device};
33use crate::util::ffmpeg_utils::{hashmap_to_avdictionary, DictGuard};
34#[cfg(not(docsrs))]
35use ffmpeg_sys_next::AVChannelOrder::AV_CHANNEL_ORDER_UNSPEC;
36#[cfg(not(docsrs))]
37use ffmpeg_sys_next::AVCodecConfig::*;
38use ffmpeg_sys_next::AVCodecID::{AV_CODEC_ID_AC3, AV_CODEC_ID_MP3, AV_CODEC_ID_NONE};
39use ffmpeg_sys_next::AVColorRange::AVCOL_RANGE_UNSPECIFIED;
40use ffmpeg_sys_next::AVColorSpace::AVCOL_SPC_UNSPECIFIED;
41use ffmpeg_sys_next::AVMediaType::{
42    AVMEDIA_TYPE_ATTACHMENT, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_DATA, AVMEDIA_TYPE_SUBTITLE,
43    AVMEDIA_TYPE_VIDEO,
44};
45use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_NONE;
46use ffmpeg_sys_next::AVSampleFormat::AV_SAMPLE_FMT_NONE;
47use ffmpeg_sys_next::{
48    av_add_q, av_channel_layout_default, av_codec_get_id, av_codec_get_tag2, av_freep,
49    av_get_exact_bits_per_sample, av_get_pix_fmt, av_guess_codec, av_guess_format,
50    av_guess_frame_rate, av_inv_q, av_malloc, av_rescale_q, av_seek_frame, avcodec_alloc_context3,
51    avcodec_descriptor_get, avcodec_descriptor_get_by_name, avcodec_find_encoder,
52    avcodec_find_encoder_by_name, avcodec_get_name, avcodec_parameters_from_context,
53    avcodec_parameters_to_context, avformat_alloc_context, avformat_alloc_output_context2,
54    avformat_close_input, avformat_find_stream_info, avformat_flush, avformat_open_input,
55    avio_alloc_context, AVCodec, AVCodecID, AVColorRange, AVColorSpace, AVFormatContext,
56    AVMediaType, AVOutputFormat, AVPixelFormat, AVRational, AVSampleFormat, AVStream,
57    AVERROR_ENCODER_NOT_FOUND, AVFMT_FLAG_CUSTOM_IO, AVFMT_GLOBALHEADER, AVFMT_NOBINSEARCH,
58    AVFMT_NOFILE, AVFMT_NOGENSEARCH, AVFMT_NOSTREAMS, AVSEEK_FLAG_BACKWARD,
59    AV_CODEC_PROP_BITMAP_SUB, AV_CODEC_PROP_TEXT_SUB, AV_TIME_BASE,
60};
61#[cfg(not(docsrs))]
62use ffmpeg_sys_next::{
63    av_buffer_ref, av_channel_layout_copy, av_packet_side_data_new, avcodec_get_supported_config,
64    avfilter_graph_segment_create_filters, avfilter_graph_segment_free,
65    avfilter_graph_segment_parse, AVChannelLayout, AVFILTER_FLAG_HWDEVICE,
66};
67#[cfg(not(docsrs))]
68use ffmpeg_sys_next::{
69    avformat_query_codec, AVSTREAM_EVENT_FLAG_NEW_PACKETS, AV_DISPOSITION_ATTACHED_PIC,
70    AV_DISPOSITION_DEFAULT,
71};
72use log::{debug, error, info, warn};
73use std::collections::HashMap;
74use std::ffi::{c_void, CStr, CString};
75use std::ptr::{null, null_mut};
76use std::sync::Arc;
77
78mod fg_bind;
79#[cfg(not(docsrs))]
80mod fg_probe;
81pub(crate) mod open_input;
82mod open_output;
83mod opt_util;
84#[cfg(all(test, not(docsrs)))]
85mod per_stream_encoder_tests;
86mod writer_build;
87
88pub(crate) use writer_build::build_writer_context;
89
90/// Log target held stable across the module split so target-based log
91/// filtering and routing keep observing `ez_ffmpeg::core::context::ffmpeg_context`.
92const LOG_TARGET: &str = module_path!();
93
94use fg_bind::{fg_bind_inputs, init_filter_graphs};
95use open_input::open_input_files;
96use open_output::open_output_files;
97use opt_util::outputs_bind;
98
99pub(super) use open_input::InputOpaque;
100pub(super) use open_output::OutputOpaque;
101
102// Re-exported only so the `#[cfg(test)] mod tests` below can name these
103// relocated items via the crate-absolute `ffmpeg_context::…` path it already used.
104#[cfg(test)]
105use fg_bind::{bind_fg_inputs_by_fg, fg_complex_bind_input};
106#[cfg(test)]
107use open_input::{read_packet_wrapper, seek_input_packet_wrapper};
108#[cfg(test)]
109use open_output::{seek_output_packet_wrapper, write_packet_wrapper};
110
111/// A fully assembled FFmpeg job — inputs, filter graphs, and outputs,
112/// validated and ready to run.
113///
114/// Create one with [`FfmpegContext::builder`], then either call
115/// [`start`](FfmpegContext::start) directly or hand it to an
116/// [`FfmpegScheduler`] for explicit lifecycle control (pause/resume, abort,
117/// async waiting).
118pub struct FfmpegContext {
119    pub(crate) independent_readrate: bool,
120    pub(crate) demuxs: Vec<Demuxer>,
121    pub(crate) filter_graphs: Vec<FilterGraph>,
122    pub(crate) muxs: Vec<Muxer>,
123    /// Headless frame-push inputs ([`VideoWriter`](crate::VideoWriter)):
124    /// empty for every demuxer-driven job. `start()` drains this and spawns
125    /// one counted frame-source worker per entry, after every consumer.
126    pub(crate) frame_sources: Vec<crate::core::context::frame_source::FrameSource>,
127    // Created at build time so the AVIO interrupt callbacks installed on the
128    // input/output contexts observe the same atomic the scheduler drives.
129    pub(crate) scheduler_status: Arc<std::sync::atomic::AtomicUsize>,
130    pub(crate) interrupt_state: Arc<crate::core::context::InterruptState>,
131}
132
133// SAFETY: FfmpegContext can be sent to another thread because all its fields
134// are either Send or wrapped in thread-safe containers. The raw FFmpeg pointers
135// are only accessed from the thread that owns the FfmpegContext.
136// Note: FfmpegContext is NOT Sync because it contains non-Sync fields like
137// Box<dyn FrameFilter> which only implements Send.
138unsafe impl Send for FfmpegContext {}
139
140impl FfmpegContext {
141    /// Creates a new [`FfmpegContextBuilder`] which allows you to configure
142    /// and construct an [`FfmpegContext`] with custom inputs, outputs, filters,
143    /// and other parameters.
144    ///
145    /// # Examples
146    /// ```rust,ignore
147    /// let context = FfmpegContext::builder()
148    ///     .input("input.mp4")
149    ///     .output("output.mp4")
150    ///     .build()
151    ///     .unwrap();
152    /// ```
153    pub fn builder() -> FfmpegContextBuilder {
154        FfmpegContextBuilder::new()
155    }
156
157    /// Consumes this [`FfmpegContext`] and starts an FFmpeg job, returning
158    /// an [`FfmpegScheduler<ffmpeg_scheduler::Running>`] for further management.
159    ///
160    /// Internally, this method creates an [`FfmpegScheduler`] from the context
161    /// and immediately calls [`FfmpegScheduler::start()`].
162    ///
163    /// # Returns
164    /// - `Ok(FfmpegScheduler<Running>)` if the scheduling process started successfully.
165    /// - `Err(...)` if there was an error initializing or starting FFmpeg.
166    ///
167    /// # Example
168    /// ```rust,ignore
169    /// let context = FfmpegContext::builder()
170    ///     .input("input.mp4")
171    ///     .output("output.mp4")
172    ///     .build()
173    ///     .unwrap();
174    ///
175    /// // Start the FFmpeg job and get a scheduler to manage it
176    /// let scheduler = context.start().expect("Failed to start Ffmpeg job");
177    ///
178    /// // Optionally, wait for it to finish
179    /// let result = scheduler.wait();
180    /// assert!(result.is_ok());
181    /// ```
182    pub fn start(self) -> Result<FfmpegScheduler<ffmpeg_scheduler::Running>> {
183        let ffmpeg_scheduler = FfmpegScheduler::new(self);
184        ffmpeg_scheduler.start()
185    }
186
187    #[allow(dead_code)]
188    pub(crate) fn new(
189        inputs: Vec<Input>,
190        filter_complexs: Vec<FilterComplex>,
191        outputs: Vec<Output>,
192    ) -> Result<FfmpegContext> {
193        Self::new_with_options(false, inputs, filter_complexs, outputs, false, Vec::new())
194    }
195
196    pub(crate) fn new_with_options(
197        mut independent_readrate: bool,
198        mut inputs: Vec<Input>,
199        mut filter_complexs: Vec<FilterComplex>,
200        mut outputs: Vec<Output>,
201        copy_ts: bool,
202        deferred_filter_descs: Vec<
203            crate::core::context::ffmpeg_context_builder::DeferredFilterDesc,
204        >,
205    ) -> Result<FfmpegContext> {
206        check_duplicate_inputs_outputs(&inputs, &outputs)?;
207
208        crate::core::initialize_ffmpeg();
209
210        // The status atomic exists before any context is opened so every
211        // input/output AVFormatContext can carry an interrupt callback bound
212        // to it (fftools installs decode_interrupt_cb the same way).
213        let scheduler_status = Arc::new(std::sync::atomic::AtomicUsize::new(
214            crate::core::scheduler::ffmpeg_scheduler::STATUS_INIT,
215        ));
216        let interrupt_state = Arc::new(crate::core::context::InterruptState::new(
217            scheduler_status.clone(),
218        ));
219
220        let mut demuxs = open_input_files(&mut inputs, copy_ts, &interrupt_state)?;
221
222        if demuxs.len() <= 1 {
223            independent_readrate = false;
224        }
225
226        // Resolve deferred filter descriptions against the just-opened demuxers
227        // (stream selection, durations, codec parameters) and append them BEFORE
228        // the emptiness check below, so the resulting graph is initialized. These
229        // closures must not consume corrected timing state
230        // (start_time_effective / ts_offset) — that is populated later by
231        // correct_input_start_times.
232        for deferred in deferred_filter_descs {
233            filter_complexs.push(deferred(&demuxs)?);
234        }
235
236        let mut filter_graphs = if !filter_complexs.is_empty() {
237            let mut filter_graphs = init_filter_graphs(filter_complexs)?;
238            fg_bind_inputs(&mut filter_graphs, &mut demuxs)?;
239            filter_graphs
240        } else {
241            Vec::new()
242        };
243
244        let mut muxs = open_output_files(&mut outputs, copy_ts, &interrupt_state)?;
245
246        outputs_bind(&mut muxs, &mut filter_graphs, &mut demuxs)?;
247
248        // Propagate input recording_time to mux as a convenience feature.
249        // This allows users to set recording_time on Input and have it work
250        // correctly for stream-copy scenarios (where the mux-side check in
251        // streamcopy_rescale needs recording_time). Only propagate when all
252        // mapped streams come from the same input file to avoid incorrect
253        // truncation in multi-input scenarios.
254        for mux in muxs.iter_mut() {
255            if mux.recording_time_us.is_none() {
256                let mapping = mux.stream_input_mapping();
257                if !mapping.is_empty() {
258                    let first_input = mapping[0].1 .0;
259                    let all_same_input = mapping.iter().all(|(_, (idx, _))| *idx == first_input);
260                    if all_same_input {
261                        if let Some(demux) = demuxs.get(first_input) {
262                            if let Some(recording_time) = demux.recording_time_us {
263                                mux.recording_time_us = Some(recording_time);
264                            }
265                        }
266                    }
267                }
268            }
269        }
270
271        correct_input_start_times(&mut demuxs, copy_ts);
272
273        check_output_streams(&muxs)?;
274
275        check_fg_bindings(&filter_graphs)?;
276
277        check_frame_filter_pipeline(&muxs, &demuxs)?;
278
279        Ok(Self {
280            independent_readrate,
281            demuxs,
282            filter_graphs,
283            muxs,
284            frame_sources: Vec::new(),
285            scheduler_status,
286            interrupt_state,
287        })
288    }
289}
290
291const START_AT_ZERO: bool = false;
292
293fn correct_input_start_times(demuxs: &mut Vec<Demuxer>, copy_ts: bool) {
294    for (i, demux) in demuxs.iter_mut().enumerate() {
295        unsafe {
296            let is = demux.in_fmt_ctx_ptr();
297
298            demux.start_time_effective = (*is).start_time;
299            if (*is).start_time == ffmpeg_sys_next::AV_NOPTS_VALUE
300                || (*(*is).iformat).flags & ffmpeg_sys_next::AVFMT_TS_DISCONT == 0
301            {
302                continue;
303            }
304
305            let mut new_start_time = i64::MAX;
306            let stream_count = (*is).nb_streams;
307            for j in 0..stream_count {
308                let st = *(*is).streams.add(j as usize);
309                if (*st).discard == ffmpeg_sys_next::AVDiscard::AVDISCARD_ALL
310                    || (*st).start_time == ffmpeg_sys_next::AV_NOPTS_VALUE
311                {
312                    continue;
313                }
314                new_start_time = std::cmp::min(
315                    new_start_time,
316                    av_rescale_q(
317                        (*st).start_time,
318                        (*st).time_base,
319                        ffmpeg_sys_next::AV_TIME_BASE_Q,
320                    ),
321                );
322            }
323            let diff = new_start_time - (*is).start_time;
324            if diff != 0 {
325                debug!("Correcting start time of Input #{i} by {diff}us.");
326                demux.start_time_effective = new_start_time;
327                if copy_ts && START_AT_ZERO {
328                    demux.ts_offset = -new_start_time;
329                } else if !copy_ts {
330                    let abs_start_seek = (*is).start_time + demux.start_time_us.unwrap_or(0);
331                    demux.ts_offset = if abs_start_seek > new_start_time {
332                        -abs_start_seek
333                    } else {
334                        -new_start_time
335                    };
336                } else if copy_ts {
337                    demux.ts_offset = 0;
338                }
339
340                // demux.ts_offset += demux.input_ts_offset;
341            }
342        }
343    }
344}
345
346fn check_pipeline<T>(
347    frame_pipelines: Option<&Vec<FramePipeline>>,
348    streams: &[T],
349    tag: &str,
350    get_stream_index: impl Fn(&T) -> usize,
351    get_codec_type: impl Fn(&T) -> &AVMediaType,
352) -> Result<()> {
353    let tag_cap = {
354        let mut chars = tag.chars();
355        match chars.next() {
356            None => String::new(),
357            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
358        }
359    };
360
361    frame_pipelines
362        .into_iter()
363        .flat_map(|pipelines| pipelines.iter())
364        .try_for_each(|pipeline| {
365            if let Some(idx) = pipeline.stream_index {
366                streams
367                    .iter()
368                    .any(|s| {
369                        get_stream_index(s) == idx && get_codec_type(s) == &pipeline.media_type
370                    })
371                    .then_some(())
372                    .ok_or_else(|| {
373                        Into::<crate::error::Error>::into(FrameFilterStreamTypeNoMatched(
374                            tag_cap.clone(),
375                            idx,
376                            format!("{:?}", pipeline.media_type),
377                        ))
378                    })
379            } else {
380                streams
381                    .iter()
382                    .any(|s| get_codec_type(s) == &pipeline.media_type)
383                    .then_some(())
384                    .ok_or_else(|| {
385                        FrameFilterTypeNoMatched(tag.into(), format!("{:?}", pipeline.media_type))
386                    })
387            }
388        })?;
389    Ok(())
390}
391
392fn check_frame_filter_pipeline(muxs: &[Muxer], demuxs: &[Demuxer]) -> Result<()> {
393    muxs.iter().try_for_each(|mux| {
394        check_pipeline(
395            mux.frame_pipelines.as_ref(),
396            mux.get_streams(),
397            "output",
398            |s| s.stream_index,
399            |s| &s.codec_type,
400        )
401    })?;
402    demuxs.iter().try_for_each(|demux| {
403        check_pipeline(
404            demux.frame_pipelines.as_ref(),
405            demux.get_streams(),
406            "input",
407            |s| s.stream_index,
408            |s| &s.codec_type,
409        )
410    })?;
411    Ok(())
412}
413
414fn check_fg_bindings(filter_graphs: &Vec<FilterGraph>) -> Result<()> {
415    // check that all outputs were bound
416    for filter_graph in filter_graphs {
417        for (i, output_filter) in filter_graph.outputs.iter().enumerate() {
418            if !output_filter.has_dst() {
419                let linklabel = if output_filter.linklabel.is_empty() {
420                    "unlabeled".to_string()
421                } else {
422                    output_filter.linklabel.clone()
423                };
424                return Err(OutputUnconnected(output_filter.name.clone(), i, linklabel).into());
425            }
426        }
427    }
428    Ok(())
429}
430
431impl From<FfmpegContext> for FfmpegScheduler<Initialization> {
432    fn from(val: FfmpegContext) -> Self {
433        FfmpegScheduler::new(val)
434    }
435}
436
437fn check_output_streams(muxs: &Vec<Muxer>) -> Result<()> {
438    for mux in muxs {
439        unsafe {
440            let oformat = (*mux.out_fmt_ctx_ptr()).oformat;
441            if !mux.has_src() && (*oformat).flags & AVFMT_NOSTREAMS == 0 {
442                // Packet sinks report their own typed zero-stream error; the
443                // AVFMT_NOSTREAMS escape does not apply to them (the dummy
444                // parameter container is an implementation detail).
445                if mux.is_packet_sink() {
446                    warn!("Packet-sink output does not contain any stream");
447                    return Err(crate::error::PacketSinkError::NoStreams.into());
448                }
449                warn!("Output file does not contain any stream");
450                return Err(OpenOutputError::NotContainStream.into());
451            }
452        }
453    }
454    Ok(())
455}
456
457fn check_duplicate_inputs_outputs(inputs: &[Input], outputs: &[Output]) -> Result<()> {
458    for output in outputs {
459        if let Some(output_url) = output.url() {
460            for input in inputs {
461                if let Some(input_url) = &input.url {
462                    if input_url == output_url {
463                        return Err(FileSameAsInput(input_url.clone()));
464                    }
465                }
466            }
467        }
468    }
469    Ok(())
470}
471
472/// Similar to strtol() in C
473/// FFmpeg reference: ffmpeg_opt.c:512 - strtol(arg, &endptr, 0)
474/// Used for parsing file indices and other integers in stream specifiers
475fn strtol(input: &str) -> Result<(i64, &str)> {
476    let mut chars = input.chars().peekable();
477    let mut negative = false;
478
479    if let Some(&ch) = chars.peek() {
480        if ch == '-' {
481            negative = true;
482            chars.next();
483        } else if !ch.is_ascii_digit() {
484            return Err(ParseInteger);
485        }
486    }
487
488    let number_start = input.len() - chars.clone().collect::<String>().len();
489
490    let number_str: String = chars
491        .by_ref()
492        .take_while(|ch| ch.is_ascii_digit())
493        .collect();
494
495    if number_str.is_empty() {
496        return Err(ParseInteger);
497    }
498
499    let number: i64 = number_str.parse().map_err(|_| ParseInteger)?;
500
501    let remainder_index = number_start + number_str.len();
502    let remainder = &input[remainder_index..];
503
504    if negative {
505        Ok((-number, remainder))
506    } else {
507        Ok((number, remainder))
508    }
509}
510
511fn convert_options(
512    opts: Option<HashMap<String, String>>,
513) -> Result<Option<HashMap<CString, CString>>> {
514    if opts.is_none() {
515        return Ok(None);
516    }
517
518    let converted = opts.map(|map| {
519        map.into_iter()
520            .map(|(k, v)| Ok((CString::new(k)?, CString::new(v)?)))
521            .collect::<Result<HashMap<CString, CString>, _>>() // Collect into a HashMap
522    });
523
524    converted.transpose() // Convert `Result<Option<T>>` into `Option<Result<T>>`
525}
526
527#[cfg(test)]
528mod tests {
529    use std::ffi::{CStr, CString};
530    use std::ptr::null_mut;
531
532    use crate::core::context::ffmpeg_context::{strtol, FfmpegContext, Output};
533    use ffmpeg_sys_next::avfilter_graph_parse_ptr;
534
535    use crate::core::context::ffmpeg_context::{bind_fg_inputs_by_fg, fg_complex_bind_input};
536    use crate::core::context::ffmpeg_context::{
537        read_packet_wrapper, seek_input_packet_wrapper, seek_output_packet_wrapper,
538        write_packet_wrapper, InputOpaque, OutputOpaque,
539    };
540    use crate::core::context::filter_graph::FilterGraph;
541    use crate::core::context::input_filter::InputFilter;
542    use crate::core::context::null_frame;
543    use crate::core::context::output_filter::OutputFilter;
544    use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO;
545
546    fn test_input(linklabel: &str, name: &str) -> InputFilter {
547        InputFilter::new(
548            linklabel.to_string(),
549            AVMEDIA_TYPE_VIDEO,
550            name.to_string(),
551            null_frame(),
552        )
553    }
554
555    fn test_graph(inputs: Vec<InputFilter>, outputs: Vec<OutputFilter>) -> FilterGraph {
556        FilterGraph::new("null".to_string(), inputs, outputs, None, None)
557    }
558
559    // The REAL builder must leave a cross-graph-bound pad as a `None` hole in the
560    // consumer node's pad-indexed scheduler-input list, with the demuxer-bound
561    // pad in its own slot — not a shifted/dense list. FC0 outputs `[mid]` (the
562    // consumer's pad 0, cross-graph); `[1:v]` is a demuxer stream (pad 1). This
563    // pins the structure the runtime relies on (a shifted list would mis-route
564    // demuxer choke-balancing).
565    #[test]
566    fn builder_leaves_cross_graph_pad_as_a_hole_in_scheduler_inputs() {
567        use crate::core::context::input::Input;
568        use crate::core::scheduler::input_controller::SchNode;
569
570        let out = std::env::temp_dir().join(format!(
571            "ez_ffmpeg_xgraph_struct_{}.mp4",
572            std::process::id()
573        ));
574        let ctx = FfmpegContext::builder()
575            .input(Input::from("color=c=red:s=64x64:r=15:d=0.2").set_format("lavfi"))
576            .input(Input::from("color=c=blue:s=64x64:r=15:d=0.2").set_format("lavfi"))
577            .filter_desc("[0:v]hue=s=0[mid]")
578            .filter_desc("[mid][1:v]overlay[vout]")
579            .output(
580                Output::from(out.to_str().unwrap())
581                    .add_stream_map("vout")
582                    .set_video_codec("mpeg4"),
583            )
584            .build()
585            .expect("cross-graph build");
586
587        let consumer = ctx
588            .filter_graphs
589            .iter()
590            .find(|fg| fg.graph_desc.contains("overlay"))
591            .expect("consumer graph present");
592        let SchNode::Filter { inputs, .. } = consumer.node.as_ref() else {
593            panic!("consumer node must be a Filter");
594        };
595        assert_eq!(inputs.len(), 2, "one scheduler-input slot per filter pad");
596        assert!(inputs[0].is_none(), "pad 0 ([mid]) is a cross-graph hole");
597        assert!(inputs[1].is_some(), "pad 1 ([1:v]) binds a demuxer");
598    }
599
600    #[test]
601    fn cross_graph_binding_uses_input_pad_index_and_marks_bound() {
602        // Producer (graph 0): one output labeled "mid".
603        // Consumer (graph 1): pad 0 labeled "mid", pad 1 unlabeled.
604        // The consumer's GRAPH index (1) differs from the matching PAD
605        // index (0) on purpose: routing and finished_flag_list indexing
606        // work per input pad, not per graph.
607        let producer = test_graph(
608            vec![test_input("", "in0")],
609            vec![OutputFilter::new(
610                "mid".to_string(),
611                AVMEDIA_TYPE_VIDEO,
612                "out0".to_string(),
613            )],
614        );
615        let consumer = test_graph(
616            vec![test_input("mid", "in0"), test_input("", "in1")],
617            vec![OutputFilter::new(
618                String::new(),
619                AVMEDIA_TYPE_VIDEO,
620                "out0".to_string(),
621            )],
622        );
623        let mut graphs = vec![producer, consumer];
624
625        bind_fg_inputs_by_fg(&mut graphs).unwrap();
626
627        assert!(
628            graphs[0].outputs[0].has_dst(),
629            "producer output must be connected to the consumer"
630        );
631        assert_eq!(
632            graphs[0].outputs[0].fg_input_index, 0,
633            "fg_input_index must be the consumer's input PAD index, not its graph index"
634        );
635        assert_eq!(
636            graphs[0].outputs[0].finished_flag_list.len(),
637            2,
638            "the producer must hold the consumer's per-pad finished flags"
639        );
640        assert!(
641            graphs[1].inputs[0].bound,
642            "the cross-connected pad must be marked bound"
643        );
644        assert!(
645            !graphs[1].inputs[1].bound,
646            "unrelated pads must stay unbound"
647        );
648    }
649
650    #[test]
651    fn complex_bind_skips_already_bound_labeled_input() {
652        let mut consumer = test_graph(
653            vec![test_input("mid", "in0")],
654            vec![OutputFilter::new(
655                String::new(),
656                AVMEDIA_TYPE_VIDEO,
657                "out0".to_string(),
658            )],
659        );
660        consumer.inputs[0].bound = true;
661
662        // No demuxers exist: if the pad were (re-)bound to an input stream
663        // this would fail with "stream not found".
664        let result = fg_complex_bind_input(&mut consumer, 0, &mut Vec::new());
665        assert!(
666            result.is_ok(),
667            "a pad already bound to another graph must not be re-bound: {result:?}"
668        );
669    }
670
671    #[test]
672    fn complex_bind_skips_bound_reserved_in_label() {
673        // The reserved label "in" takes the auto-bind branch, which must
674        // also respect an existing cross-graph binding.
675        let mut consumer = test_graph(
676            vec![test_input("in", "in0")],
677            vec![OutputFilter::new(
678                String::new(),
679                AVMEDIA_TYPE_VIDEO,
680                "out0".to_string(),
681            )],
682        );
683        consumer.inputs[0].bound = true;
684
685        let result = fg_complex_bind_input(&mut consumer, 0, &mut Vec::new());
686        assert!(
687            result.is_ok(),
688            "a bound pad labeled 'in' must not fall through to stream auto-binding: {result:?}"
689        );
690    }
691
692    #[test]
693    fn test_filter() {
694        let desc_cstr = CString::new("[1:v][2:v]concat=n=2:v=1:a=0[vout]").unwrap();
695        // let desc_cstr = CString::new("fps=15").unwrap();
696
697        unsafe {
698            let graph = crate::raw::FilterGraph::alloc().unwrap();
699            let mut inputs = crate::raw::FilterInOut::empty();
700            let mut outputs = crate::raw::FilterInOut::empty();
701
702            let ret = avfilter_graph_parse_ptr(
703                graph.as_ptr(),
704                desc_cstr.as_ptr(),
705                inputs.as_out_ptr(),
706                outputs.as_out_ptr(),
707                null_mut(),
708            );
709            if ret < 0 {
710                println!("err ret:{}", crate::util::ffmpeg_utils::av_err2str(ret));
711                return;
712            }
713
714            println!("inputs.is_null:{}", inputs.as_ptr().is_null());
715            println!("outputs.is_null:{}", outputs.as_ptr().is_null());
716
717            let mut cur = inputs.as_ptr();
718            while !cur.is_null() {
719                let input_name = CStr::from_ptr((*cur).name);
720                println!("Input name: {}", input_name.to_str().unwrap());
721                cur = (*cur).next;
722            }
723
724            let output_name = CStr::from_ptr((*outputs.as_ptr()).name);
725            println!("Output name: {}", output_name.to_str().unwrap());
726
727            let filter_ctx = (*outputs.as_ptr()).filter_ctx;
728            println!("filter_ctx.is_null:{}", filter_ctx.is_null());
729        }
730    }
731
732    #[test]
733    fn fallback_frame_carries_real_stream_parameters() {
734        // Regression: the probe dec_ctx used to stay at codec defaults, so
735        // the fallback frame bound to each filtergraph input had format=-1
736        // and the EOF-before-first-frame path could never configure a graph.
737        let ctx = FfmpegContext::new(
738            vec!["test.mp4".into()],
739            vec!["hue=s=0".into()],
740            vec!["output_fallback_probe.mp4".to_string().into()],
741        )
742        .unwrap();
743        let fallback = unsafe { &*ctx.filter_graphs[0].inputs[0].opts.fallback.as_ptr() };
744        assert!(
745            fallback.format >= 0,
746            "fallback must carry the stream's real format, got {}",
747            fallback.format
748        );
749        assert!(
750            fallback.width > 0 && fallback.height > 0,
751            "fallback must carry the stream's dimensions, got {}x{}",
752            fallback.width,
753            fallback.height
754        );
755        assert!(
756            fallback.time_base.num > 0 && fallback.time_base.den > 0,
757            "fallback must carry the stream's packet time base, got {}/{}",
758            fallback.time_base.num,
759            fallback.time_base.den
760        );
761    }
762
763    #[test]
764    fn test_new() {
765        let _ = env_logger::builder()
766            .filter_level(log::LevelFilter::Debug)
767            .is_test(true)
768            .try_init();
769        let _ffmpeg_context = FfmpegContext::new(
770            vec!["test.mp4".to_string().into()],
771            vec!["hue=s=0".to_string().into()],
772            vec!["output.mp4".to_string().into()],
773        )
774        .unwrap();
775        let _ffmpeg_context = FfmpegContext::new(
776            vec!["test.mp4".into()],
777            vec!["[0:v]hue=s=0".into()],
778            vec!["output.mp4".to_string().into()],
779        )
780        .unwrap();
781        let _ffmpeg_context = FfmpegContext::new(
782            vec!["test.mp4".into()],
783            vec!["hue=s=0[my-out]".into()],
784            vec![Output::from("output.mp4").add_stream_map("my-out")],
785        )
786        .unwrap();
787        let result = FfmpegContext::new(
788            vec!["test.mp4".into()],
789            vec!["hue=s=0".into()],
790            vec![Output::from("output.mp4").add_stream_map("0:v?")],
791        );
792        assert!(result.is_err());
793        let result = FfmpegContext::new(
794            vec!["test.mp4".into()],
795            vec!["hue=s=0".into()],
796            vec![Output::from("output.mp4").add_stream_map_with_copy("1:v?")],
797        );
798        assert!(result.is_err());
799        let result = FfmpegContext::new(
800            vec!["test.mp4".into()],
801            vec!["hue=s=0[fg-out]".into()],
802            vec![
803                Output::from("output.mp4").add_stream_map("my-out?"),
804                Output::from("output.mp4").add_stream_map("fg-out"),
805            ],
806        );
807        assert!(result.is_err());
808        // ignore filter
809        let result = FfmpegContext::new(
810            vec!["test.mp4".into()],
811            vec!["hue=s=0".into()],
812            vec![Output::from("output.mp4").add_stream_map_with_copy("1:v")],
813        );
814        assert!(result.is_err());
815        let result = FfmpegContext::new(
816            vec!["test.mp4".into()],
817            vec!["hue=s=0[fg-out]".into()],
818            vec![Output::from("output.mp4").add_stream_map("fg-out?")],
819        );
820        assert!(result.is_err());
821    }
822
823    #[test]
824    fn test_builder() {
825        let _ = env_logger::builder()
826            .filter_level(log::LevelFilter::Debug)
827            .is_test(true)
828            .try_init();
829
830        let _context1 = FfmpegContext::builder()
831            .input("test.mp4")
832            .filter_desc("hue=s=0")
833            .output("output.mp4")
834            .build()
835            .unwrap();
836
837        let _context2 = FfmpegContext::builder()
838            .inputs(vec!["test.mp4"])
839            .filter_descs(vec!["hue=s=0"])
840            .outputs(vec!["output.mp4"])
841            .build()
842            .unwrap();
843    }
844
845    #[test]
846    fn test_strtol() {
847        let input = "-123---abc";
848        let result = strtol(input);
849        assert_eq!(result.unwrap(), (-123, "---abc"));
850
851        let input = "123---abc";
852        let result = strtol(input);
853        assert_eq!(result.unwrap(), (123, "---abc"));
854
855        let input = "-123aa";
856        let result = strtol(input);
857        assert_eq!(result.unwrap(), (-123, "aa"));
858
859        let input = "-aa";
860        let result = strtol(input);
861        assert!(result.is_err());
862
863        let input = "abc";
864        let result = strtol(input);
865        assert!(result.is_err())
866    }
867
868    // ---- custom-IO wrapper hardening (deterministic, no FFmpeg involved) ----
869    //
870    // The wrappers are plain extern "C" fns; driving them directly with a
871    // fabricated opaque pins their exact contract: oversized read lengths are
872    // clamped to EIO, a panicking closure is contained AND poisons the
873    // context, and a poisoned context never re-enters user code.
874
875    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
876    use std::sync::Arc;
877
878    fn eio() -> libc::c_int {
879        ffmpeg_sys_next::AVERROR(ffmpeg_sys_next::EIO)
880    }
881
882    #[test]
883    fn read_wrapper_clamps_oversized_length_and_allows_exact_fit() {
884        let opaque = Box::into_raw(Box::new(InputOpaque {
885            read: Box::new(|buf| buf.len() as i32 + 64),
886            seek: None,
887            poisoned: false,
888        }));
889        let mut buf = [0u8; 32];
890        let ret = unsafe {
891            read_packet_wrapper(
892                opaque as *mut libc::c_void,
893                buf.as_mut_ptr(),
894                buf.len() as libc::c_int,
895            )
896        };
897        assert_eq!(ret, eio(), "a forged over-length must clamp to EIO");
898        unsafe {
899            // Not poisoned by a bogus length: an exact-fit read stays legal.
900            (*opaque).read = Box::new(|buf| buf.len() as i32);
901            let ret = read_packet_wrapper(
902                opaque as *mut libc::c_void,
903                buf.as_mut_ptr(),
904                buf.len() as libc::c_int,
905            );
906            assert_eq!(
907                ret,
908                buf.len() as libc::c_int,
909                "ret == buf_size is within bounds and must pass through"
910            );
911            drop(Box::from_raw(opaque));
912        }
913    }
914
915    #[test]
916    fn read_wrapper_contains_panic_and_poisons() {
917        let calls = Arc::new(AtomicUsize::new(0));
918        let probe = Arc::clone(&calls);
919        let opaque = Box::into_raw(Box::new(InputOpaque {
920            read: Box::new(move |_buf| {
921                probe.fetch_add(1, AtomicOrdering::SeqCst);
922                panic!("test-injected read panic");
923            }),
924            seek: None,
925            poisoned: false,
926        }));
927        let mut buf = [0u8; 32];
928        for _ in 0..3 {
929            let ret = unsafe {
930                read_packet_wrapper(
931                    opaque as *mut libc::c_void,
932                    buf.as_mut_ptr(),
933                    buf.len() as libc::c_int,
934                )
935            };
936            assert_eq!(ret, eio());
937        }
938        assert_eq!(
939            calls.load(AtomicOrdering::SeqCst),
940            1,
941            "the panicking closure must never be re-entered once poisoned"
942        );
943        unsafe { drop(Box::from_raw(opaque)) };
944    }
945
946    #[test]
947    fn write_wrapper_contains_panic_and_poisons() {
948        let calls = Arc::new(AtomicUsize::new(0));
949        let probe = Arc::clone(&calls);
950        let opaque = Box::into_raw(Box::new(OutputOpaque {
951            write: Box::new(move |_buf| {
952                probe.fetch_add(1, AtomicOrdering::SeqCst);
953                panic!("test-injected write panic");
954            }),
955            seek: None,
956            poisoned: false,
957        }));
958        let buf = [0u8; 32];
959        for _ in 0..3 {
960            let ret = unsafe {
961                write_packet_wrapper(
962                    opaque as *mut libc::c_void,
963                    buf.as_ptr(),
964                    buf.len() as libc::c_int,
965                )
966            };
967            assert_eq!(ret, eio());
968        }
969        assert_eq!(
970            calls.load(AtomicOrdering::SeqCst),
971            1,
972            "the panicking closure must never be re-entered once poisoned"
973        );
974        unsafe { drop(Box::from_raw(opaque)) };
975    }
976
977    #[test]
978    fn all_avio_wrappers_dispose_panicking_panic_payloads() {
979        struct PanicOnDrop(Arc<AtomicUsize>);
980
981        impl Drop for PanicOnDrop {
982            fn drop(&mut self) {
983                self.0.fetch_add(1, AtomicOrdering::SeqCst);
984                panic!("test panic payload destructor");
985            }
986        }
987
988        let payload_drops = Arc::new(AtomicUsize::new(0));
989        let mut read_buf = [0u8; 8];
990        let write_buf = [0u8; 8];
991
992        let read_drop = Arc::clone(&payload_drops);
993        let input_read = Box::into_raw(Box::new(InputOpaque {
994            read: Box::new(move |_buf| -> i32 {
995                std::panic::panic_any(PanicOnDrop(Arc::clone(&read_drop)))
996            }),
997            seek: None,
998            poisoned: false,
999        }));
1000        let ret = unsafe {
1001            read_packet_wrapper(
1002                input_read as *mut libc::c_void,
1003                read_buf.as_mut_ptr(),
1004                read_buf.len() as libc::c_int,
1005            )
1006        };
1007        assert_eq!(ret, eio());
1008        unsafe { drop(Box::from_raw(input_read)) };
1009
1010        let seek_drop = Arc::clone(&payload_drops);
1011        let input_seek = Box::into_raw(Box::new(InputOpaque {
1012            read: Box::new(|buf| buf.len() as i32),
1013            seek: Some(Box::new(move |_offset, _whence| -> i64 {
1014                std::panic::panic_any(PanicOnDrop(Arc::clone(&seek_drop)))
1015            })),
1016            poisoned: false,
1017        }));
1018        let ret = unsafe { seek_input_packet_wrapper(input_seek.cast(), 0, 0) };
1019        assert_eq!(ret, eio() as i64);
1020        unsafe { drop(Box::from_raw(input_seek)) };
1021
1022        let write_drop = Arc::clone(&payload_drops);
1023        let output_write = Box::into_raw(Box::new(OutputOpaque {
1024            write: Box::new(move |_buf| -> i32 {
1025                std::panic::panic_any(PanicOnDrop(Arc::clone(&write_drop)))
1026            }),
1027            seek: None,
1028            poisoned: false,
1029        }));
1030        let ret = unsafe {
1031            write_packet_wrapper(
1032                output_write.cast(),
1033                write_buf.as_ptr(),
1034                write_buf.len() as libc::c_int,
1035            )
1036        };
1037        assert_eq!(ret, eio());
1038        unsafe { drop(Box::from_raw(output_write)) };
1039
1040        let output_seek_drop = Arc::clone(&payload_drops);
1041        let output_seek = Box::into_raw(Box::new(OutputOpaque {
1042            write: Box::new(|buf| buf.len() as i32),
1043            seek: Some(Box::new(move |_offset, _whence| -> i64 {
1044                std::panic::panic_any(PanicOnDrop(Arc::clone(&output_seek_drop)))
1045            })),
1046            poisoned: false,
1047        }));
1048        let ret = unsafe { seek_output_packet_wrapper(output_seek.cast(), 0, 0) };
1049        assert_eq!(ret, eio() as i64);
1050        unsafe { drop(Box::from_raw(output_seek)) };
1051
1052        assert_eq!(
1053            payload_drops.load(AtomicOrdering::SeqCst),
1054            4,
1055            "every caught panic payload destructor must run under containment"
1056        );
1057    }
1058
1059    #[test]
1060    fn seek_panic_poisons_the_whole_input_context() {
1061        let reads = Arc::new(AtomicUsize::new(0));
1062        let read_probe = Arc::clone(&reads);
1063        let opaque = Box::into_raw(Box::new(InputOpaque {
1064            read: Box::new(move |buf| {
1065                read_probe.fetch_add(1, AtomicOrdering::SeqCst);
1066                buf.len() as i32
1067            }),
1068            seek: Some(Box::new(|_offset, _whence| {
1069                panic!("test-injected seek panic")
1070            })),
1071            poisoned: false,
1072        }));
1073        let mut buf = [0u8; 32];
1074        unsafe {
1075            // Healthy read before the panic.
1076            let ret = read_packet_wrapper(
1077                opaque as *mut libc::c_void,
1078                buf.as_mut_ptr(),
1079                buf.len() as libc::c_int,
1080            );
1081            assert_eq!(ret, buf.len() as libc::c_int);
1082
1083            // The panicking seek is contained as EIO (not ESPIPE, which
1084            // would let FFmpeg fall back to non-seeking modes and mask it).
1085            let ret = seek_input_packet_wrapper(opaque as *mut libc::c_void, 0, 0);
1086            assert_eq!(ret, eio() as i64);
1087
1088            // Cross-callback poison: the read closure must not run again.
1089            let ret = read_packet_wrapper(
1090                opaque as *mut libc::c_void,
1091                buf.as_mut_ptr(),
1092                buf.len() as libc::c_int,
1093            );
1094            assert_eq!(ret, eio());
1095            drop(Box::from_raw(opaque));
1096        }
1097        assert_eq!(
1098            reads.load(AtomicOrdering::SeqCst),
1099            1,
1100            "a seek panic must poison reads on the same context"
1101        );
1102    }
1103
1104    /// H12: a short-writing sink (io::Write-style) must not lose the
1105    /// remainder — the wrapper resubmits until the whole buffer went out.
1106    #[test]
1107    fn write_wrapper_resubmits_short_writes() {
1108        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1109        let probe = Arc::clone(&seen);
1110        let opaque = Box::into_raw(Box::new(OutputOpaque {
1111            write: Box::new(move |buf| {
1112                // Write at most 10 bytes per call, recording each chunk.
1113                let n = buf.len().min(10);
1114                probe.lock().unwrap().extend_from_slice(&buf[..n]);
1115                n as i32
1116            }),
1117            seek: None,
1118            poisoned: false,
1119        }));
1120        let data: Vec<u8> = (0..64u8).collect();
1121        let ret = unsafe {
1122            write_packet_wrapper(
1123                opaque as *mut libc::c_void,
1124                data.as_ptr(),
1125                data.len() as libc::c_int,
1126            )
1127        };
1128        assert_eq!(
1129            ret,
1130            data.len() as libc::c_int,
1131            "the whole buffer must report written"
1132        );
1133        assert_eq!(
1134            *seen.lock().unwrap(),
1135            data,
1136            "no byte may be lost or reordered"
1137        );
1138        unsafe { drop(Box::from_raw(opaque)) };
1139    }
1140
1141    /// H12: zero progress and over-claimed lengths are I/O faults, not
1142    /// silent success.
1143    #[test]
1144    fn write_wrapper_rejects_zero_progress_and_over_claims() {
1145        let opaque = Box::into_raw(Box::new(OutputOpaque {
1146            write: Box::new(|_buf| 0),
1147            seek: None,
1148            poisoned: false,
1149        }));
1150        let data = [7u8; 16];
1151        let ret = unsafe { write_packet_wrapper(opaque as *mut libc::c_void, data.as_ptr(), 16) };
1152        assert_eq!(
1153            ret,
1154            eio(),
1155            "a zero-progress sink must fail, not spin or succeed"
1156        );
1157        unsafe {
1158            (*opaque).write = Box::new(|buf| buf.len() as i32 + 4);
1159            let ret = write_packet_wrapper(opaque as *mut libc::c_void, data.as_ptr(), 16);
1160            assert_eq!(ret, eio(), "an over-claimed write length must fail");
1161            drop(Box::from_raw(opaque));
1162        }
1163    }
1164
1165    /// A negative error from the sink passes through unchanged.
1166    #[test]
1167    fn write_wrapper_passes_sink_errors_through() {
1168        let opaque = Box::into_raw(Box::new(OutputOpaque {
1169            write: Box::new(|_buf| ffmpeg_sys_next::AVERROR(ffmpeg_sys_next::ENOSPC)),
1170            seek: None,
1171            poisoned: false,
1172        }));
1173        let data = [7u8; 8];
1174        let ret = unsafe { write_packet_wrapper(opaque as *mut libc::c_void, data.as_ptr(), 8) };
1175        assert_eq!(ret, ffmpeg_sys_next::AVERROR(ffmpeg_sys_next::ENOSPC));
1176        unsafe { drop(Box::from_raw(opaque)) };
1177    }
1178
1179    #[test]
1180    fn absent_seek_callback_stays_espipe() {
1181        let opaque = Box::into_raw(Box::new(InputOpaque {
1182            read: Box::new(|buf| buf.len() as i32),
1183            seek: None,
1184            poisoned: false,
1185        }));
1186        let ret = unsafe { seek_input_packet_wrapper(opaque as *mut libc::c_void, 0, 0) };
1187        assert_eq!(
1188            ret,
1189            ffmpeg_sys_next::AVERROR(ffmpeg_sys_next::ESPIPE) as i64,
1190            "no seek callback means genuinely unseekable, not an I/O fault"
1191        );
1192        unsafe { drop(Box::from_raw(opaque)) };
1193    }
1194}