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