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