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