1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
//! The [`FrameExtractor`] builder and its `frames()` / `collect_frames()` runs.
use super::error::FrameExportError;
use super::frame::VideoFrame;
use super::guard::ColorGuard;
use super::iter::FrameIter;
use super::options::{ColorPolicy, PixelLayout, Sampling};
use super::resolve::{resolve_and_build_desc, ResolvePlan, UniformResolve};
use super::sampler::{ExportSampler, UniformSpan};
use super::selector::InputSelector;
use super::sink::ExportSink;
use crate::core::context::demuxer::Demuxer;
use crate::core::context::ffmpeg_context::FfmpegContext;
use crate::core::context::filter_complex::FilterComplex;
use crate::core::context::input::Input;
use crate::core::context::output::{Output, VSyncMethod};
use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
use crate::core::scheduler::ffmpeg_scheduler::FfmpegScheduler;
use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO;
/// Extracts packed RGB/gray frames from a video input, one pass, for AI/CV.
///
/// Build one with [`FrameExtractor::new`], chain options, then call
/// [`frames`](FrameExtractor::frames) for a streaming iterator or
/// [`collect_frames`](FrameExtractor::collect_frames) for a `Vec`.
pub struct FrameExtractor {
input: Input,
sampling: Sampling,
video_stream_index: Option<usize>,
width: Option<u32>,
height: Option<u32>,
pixel: PixelLayout,
color: ColorPolicy,
max_frames: Option<u64>,
start_time_us: Option<i64>,
duration_us: Option<i64>,
duration_hint_us: Option<i64>,
channel_capacity: usize,
}
impl FrameExtractor {
/// Creates an extractor over `input` (a path, URL, or anything convertible
/// into an [`Input`]). Defaults: all frames, source size, `Rgb24`,
/// [`ColorPolicy::Tagged`], channel capacity 1.
pub fn new(input: impl Into<Input>) -> Self {
Self {
input: input.into(),
sampling: Sampling::All,
video_stream_index: None,
width: None,
height: None,
pixel: PixelLayout::Rgb24,
color: ColorPolicy::Tagged,
max_frames: None,
start_time_us: None,
duration_us: None,
duration_hint_us: None,
channel_capacity: 1,
}
}
/// Sets the sampling strategy (default [`Sampling::All`]).
pub fn sampling(mut self, sampling: Sampling) -> Self {
self.sampling = sampling;
self
}
/// Selects an explicit video stream by absolute index (default: best video
/// stream). Also pins the input-side per-frame HDR guard / color stamp to
/// that stream — without it they bind to the first video stream, which
/// coincides with the exported one except on exotic multi-video layouts.
pub fn video_stream_index(mut self, index: usize) -> Self {
self.video_stream_index = Some(index);
self
}
/// Sets the output width in pixels; height (if unset) is derived keeping the
/// aspect ratio (`scale=W:-2`).
pub fn width(mut self, width: u32) -> Self {
self.width = Some(width);
self
}
/// Sets the output height in pixels; width (if unset) is derived keeping the
/// aspect ratio (`scale=-2:H`).
pub fn height(mut self, height: u32) -> Self {
self.height = Some(height);
self
}
/// Sets the packed pixel layout (default [`PixelLayout::Rgb24`]).
pub fn pixel(mut self, layout: PixelLayout) -> Self {
self.pixel = layout;
self
}
/// Sets the color-interpretation policy (default [`ColorPolicy::Tagged`]).
pub fn color(mut self, policy: ColorPolicy) -> Self {
self.color = policy;
self
}
/// Caps the number of exported frames. The sink owns this exact count.
pub fn max_frames(mut self, max: u64) -> Self {
self.max_frames = Some(max);
self
}
/// Seeks to this start time (microseconds) before extracting.
pub fn start_time_us(mut self, us: i64) -> Self {
self.start_time_us = Some(us);
self
}
/// Limits extraction to this many microseconds of content past the start.
pub fn duration_us(mut self, us: i64) -> Self {
self.duration_us = Some(us);
self
}
/// Supplies a duration (microseconds) for `UniformN` when the input is not
/// probeable (live/piped). Ignored by other sampling modes. Takes precedence
/// over the container/stream duration, but not over an explicit
/// [`duration_us`](FrameExtractor::duration_us) trim window.
pub fn duration_hint_us(mut self, us: i64) -> Self {
self.duration_hint_us = Some(us);
self
}
/// Sets the prefetch channel capacity (default 1, minimum 1). Each slot
/// costs one packed frame of memory.
pub fn channel_capacity(mut self, capacity: usize) -> Self {
self.channel_capacity = capacity;
self
}
/// Starts the run and returns a streaming iterator over the exported frames.
///
/// Option and stream/HDR resolution errors surface here, before any frame is
/// produced; runtime failures surface as the iterator's terminal `Err`.
pub fn frames(self) -> crate::error::Result<FrameIter> {
self.validate()?;
let capacity = self.channel_capacity.max(1);
let (tx, rx) = crossbeam_channel::bounded(capacity);
// Output-side sink on the single graph output stream ([export] => 0).
let sink = ExportSink::new(tx, self.sampling, self.pixel, self.max_frames);
let sink_pipeline = FramePipelineBuilder::new(AVMEDIA_TYPE_VIDEO)
.filter("frame_export_sink", Box::new(sink))
.set_stream_index(0)
.build();
// UniformN selects frames pre-filtergraph on the input side; the grid
// span is published through this cell at open time (§4.4) and read by
// the sampler at its first frame.
let uniform_n = match self.sampling {
Sampling::UniformN(n) => Some(n),
_ => None,
};
let span_cell: UniformSpan = std::sync::Arc::new(std::sync::OnceLock::new());
// Input decoder fast path + time window. The extractor's own options
// take precedence, but a start/duration preconfigured directly on the
// Input must feed the SAME machinery (trim boundary, span sizing) —
// otherwise it would reintroduce the GOP lead-in mis-anchoring the
// boundary exists to prevent.
let mut input = self.input;
let effective_start = self.start_time_us.or(input.start_time_us);
let effective_duration = self.duration_us.or(input.recording_time_us);
if matches!(self.sampling, Sampling::KeyframesOnly) {
input = input.set_video_codec_opt("skip_frame", "nokey");
}
if let Some(start) = self.start_time_us {
input = input.set_start_time_us(start);
}
if let Some(dur) = self.duration_us {
input = input.set_recording_time_us(dur);
} else if uniform_n.is_some() && input.recording_time_us.is_none() {
if let Some(hint) = self.duration_hint_us {
// The hint declares the sampled span; also use it as the demux
// stop boundary so an unbounded (live/piped) input terminates
// once the grid is covered instead of decoding forever. A
// recording time already set on the Input wins over the hint.
input = input.set_recording_time_us(hint);
}
}
// Input-side pipeline, every run: the color guard re-checks each
// decoded (pre-conversion) frame for HDR and applies the
// TaggedOrResolutionGuess stamp; UniformN chains its sampler after the
// guard so selection only ever sees guarded, stamped frames.
let mut builder = FramePipelineBuilder::new(AVMEDIA_TYPE_VIDEO).filter(
"frame_export_color_guard",
Box::new(ColorGuard::new(self.color)),
);
// With a start time (from the extractor or preconfigured on the
// Input) the demux timeline is re-zeroed at the request and the
// in-graph trim drops pts < 0 — but the container seek lands on a
// keyframe at or BEFORE the request, so on GOP video this pipeline
// sees negative-pts lead-in first. The boundary makes the input-side
// sampler/selector skip that lead-in instead of anchoring grids on
// (or spending selections on) frames the trim will destroy.
let trim_boundary = effective_start.map(|_| 0i64);
if let Some(n) = uniform_n {
let sampler = ExportSampler::new(n, span_cell.clone(), trim_boundary);
builder = builder.filter("frame_export_sampler", Box::new(sampler));
} else if let Some(selector) = InputSelector::for_sampling(&self.sampling, trim_boundary) {
// EveryNth/EverySec drop unselected frames before the filtergraph,
// so they never pay the scale/format conversion.
builder = builder.filter("frame_export_selector", Box::new(selector));
}
// Bind to the explicit stream when given; otherwise the video stream
// by media type (the resolver's best-stream selection coincides for
// single-video-stream inputs, the common case — the resolver rejects
// or warns on the mismatching layouts).
if let Some(idx) = self.video_stream_index {
builder = builder.set_stream_index(idx);
}
input = input.add_frame_pipeline(builder.build());
// Null output: vsync passthrough (S4) preserves 1:1 frames and PTS.
let mut output = Output::from("-")
.set_format("null")
.set_vsync_method(VSyncMethod::VsyncPassthrough)
.add_stream_map("[export]")
.add_frame_pipeline(sink_pipeline);
// UniformN emits <= n unique frames and must reach EOF for its flush, so
// it gets no encoder terminator; the sink's u64 cap stays authoritative.
if uniform_n.is_none() {
if let Some(max) = self.max_frames {
// Upstream terminator only; the sink owns the exact public cap
// (S5). If the cap exceeds i64, skip the terminator (the sink's
// u64 cap stays authoritative) rather than wrapping negative.
if let Ok(max_i64) = i64::try_from(max) {
output = output.set_max_video_frames(Some(max_i64));
}
}
}
// Deferred resolver: runs against the opened demuxer (S8/S9).
let resolve_plan = ResolvePlan {
stream_index: self.video_stream_index,
width: self.width,
height: self.height,
pixel: self.pixel,
color: self.color,
input_side_sampling: matches!(
self.sampling,
Sampling::UniformN(_) | Sampling::EveryNth(_) | Sampling::EverySec(_)
),
uniform: uniform_n.map(|_| UniformResolve {
span_cell: span_cell.clone(),
duration_hint_us: self.duration_hint_us,
duration_us: effective_duration,
start_time_us: effective_start,
}),
};
let resolver = move |demuxs: &[Demuxer]| -> crate::error::Result<FilterComplex> {
let demux = demuxs.first().ok_or(FrameExportError::NoVideoStream)?;
// SAFETY: the demuxer is opened for the lifetime of this call.
let desc = unsafe { resolve_and_build_desc(demux.in_fmt_ctx_ptr(), &resolve_plan)? };
Ok(desc.into())
};
let context = FfmpegContext::builder()
.input(input)
.output(output)
.add_deferred_filter_desc(Box::new(resolver))
.build()?;
let scheduler = FfmpegScheduler::new(context).start()?;
Ok(FrameIter::new(rx, scheduler))
}
/// Runs to completion and collects every exported frame into a `Vec`.
///
/// On a terminal error the partial frames are dropped and the error is
/// returned; use [`frames`](FrameExtractor::frames) to keep partial output.
pub fn collect_frames(self) -> crate::error::Result<Vec<VideoFrame>> {
self.frames()?.collect()
}
fn validate(&self) -> crate::error::Result<()> {
if self.channel_capacity == 0 {
return Err(invalid("channel_capacity must be >= 1"));
}
match self.sampling {
Sampling::EveryNth(0) => {
return Err(invalid("EveryNth requires n >= 1"));
}
Sampling::EverySec(s) if !s.is_finite() || s <= 0.0 => {
return Err(invalid(
"EverySec requires a finite, positive seconds value",
));
}
Sampling::UniformN(0) => {
return Err(invalid("UniformN requires n >= 1"));
}
_ => {}
}
if self.width == Some(0) {
return Err(invalid("width must be > 0"));
}
if self.height == Some(0) {
return Err(invalid("height must be > 0"));
}
if self.max_frames == Some(0) {
return Err(invalid("max_frames must be > 0"));
}
// Effective value: a recording time preconfigured on the Input feeds
// the same trim machinery, where 0 means "no limit" to FFmpeg — the
// opposite of the documented meaning.
if let Some(d) = self.duration_us.or(self.input.recording_time_us) {
if d <= 0 {
return Err(invalid(
"duration_us (or the Input's recording time) must be > 0",
));
}
}
// The hint is only consumed by UniformN; other modes ignore it as
// documented, so it is validated only where it has meaning.
if matches!(self.sampling, Sampling::UniformN(_)) {
if let Some(h) = self.duration_hint_us {
if h <= 0 {
return Err(invalid("duration_hint_us must be > 0"));
}
}
}
Ok(())
}
}
fn invalid(msg: &str) -> crate::error::Error {
FrameExportError::InvalidOption(msg.to_string()).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_every_nth_is_rejected() {
let r = FrameExtractor::new("x.mp4")
.sampling(Sampling::EveryNth(0))
.validate();
assert!(matches!(
r,
Err(crate::error::Error::FrameExport(
FrameExportError::InvalidOption(_)
))
));
}
#[test]
fn non_finite_every_sec_is_rejected() {
assert!(FrameExtractor::new("x.mp4")
.sampling(Sampling::EverySec(f64::NAN))
.validate()
.is_err());
assert!(FrameExtractor::new("x.mp4")
.sampling(Sampling::EverySec(-1.0))
.validate()
.is_err());
assert!(FrameExtractor::new("x.mp4")
.sampling(Sampling::EverySec(0.0))
.validate()
.is_err());
}
#[test]
fn zero_dimensions_and_capacity_rejected() {
assert!(FrameExtractor::new("x.mp4").width(0).validate().is_err());
assert!(FrameExtractor::new("x.mp4").height(0).validate().is_err());
assert!(FrameExtractor::new("x.mp4")
.channel_capacity(0)
.validate()
.is_err());
assert!(FrameExtractor::new("x.mp4")
.max_frames(0)
.validate()
.is_err());
}
#[test]
fn defaults_validate() {
assert!(FrameExtractor::new("x.mp4").validate().is_ok());
assert!(FrameExtractor::new("x.mp4")
.sampling(Sampling::EverySec(1.5))
.width(224)
.height(224)
.max_frames(16)
.validate()
.is_ok());
}
}