ez_ffmpeg/core/packet_sink/mod.rs
1//! Encoded-packet output: consume encoder packets directly, without a muxer.
2//!
3//! A packet sink is the fourth quadrant of the crate's I/O matrix (decoded
4//! frames out = frame export, PCM out = sample export, frames in =
5//! [`VideoWriter`](crate::VideoWriter), **encoded packets out = packet sink**).
6//! Instead of muxing packets into container bytes, the job hands each encoded
7//! packet to a consumer, normalized for WebCodecs-style use.
8//!
9//! # Experimental
10//!
11//! This module is **experimental**: introduced in 0.15, its API surface may
12//! still be reshaped in minor releases while it settles; within a minor
13//! line, patch releases will not break it. Correctness defects (ordering
14//! violations, duplicated terminal callbacks, malformed configuration
15//! records) are **not** waived by this banner — they are release blockers.
16//!
17//! # Strict tier (v1)
18//!
19//! The construction paths on [`PacketSink`](crate::packet_sink::PacketSink)
20//! build a **strict-tier** sink —
21//! [`PacketView`](crate::packet_sink::PacketView),
22//! [`PacketStreamInfo`](crate::packet_sink::PacketStreamInfo) and the
23//! callback bundle are the strict-tier contract, aligned with WebCodecs
24//! `"avc"` / AAC consumption:
25//!
26//! * **H.264 video** is delivered as avcC-configured, 4-byte length-prefixed,
27//! access-unit-complete packets. The verified wrappers are `libx264`,
28//! `h264_nvenc`, `h264_videotoolbox`, and `libopenh264`. Strict-tier
29//! delivery requires `pts >= dts`; an explicit `bf` or `max_b_frames`
30//! other than integer `"0"` is rejected at build time for
31//! `h264_videotoolbox` (`PacketSinkError::BFramesUnsupported`). Other
32//! admitted wrappers keep runtime timestamp enforcement. `max_b_frames` is a
33//! policy-recognized admission key, **not** an FFmpeg `AVOption` alias of
34//! `bf`: a leftover `max_b_frames=0` does not set the encoder's `bf` and
35//! does not guarantee `dts == pts`. Options are not
36//! rewritten. Unset B-frame keys are admitted (the wrapper's FFmpeg
37//! default applies). Admission does not guarantee that the linked FFmpeg
38//! build contains an encoder or that required hardware is available — it
39//! only lifts the build-time name rejection and the explicit B-frame
40//! check. The delivery contract assumes one packet == one access unit, a
41//! property the Trusted path does not check per packet and therefore
42//! establishes per encoder wrapper (audited against the FFmpeg versions
43//! CI pins, with a hardware acceptance line where encoding needs
44//! hardware). Any other video encoder fails the build with a typed error.
45//! `libopenh264` needs `--enable-libopenh264`; that is an LGPL-compatible
46//! **copyright** combination, not an H.264 patent grant.
47//! * **AAC audio** is delivered as raw AAC frames; the stream configuration
48//! carries the AudioSpecificConfig.
49//! * Anything else (subtitles, data streams, stream copy, bitstream filters)
50//! is rejected up front with a typed
51//! [`PacketSinkError`](crate::packet_sink::PacketSinkError).
52//!
53//! Future tiers (generic passthrough, HEVC, Annex-B) will introduce their own
54//! construction paths and view/config types, and a per-packet access-unit
55//! verifier may eventually widen video admission beyond the registry;
56//! everything here is `#[non_exhaustive]` so that growth is additive.
57//!
58//! # Callback order
59//!
60//! All callbacks run **serially on the one delivery (mux worker) thread** —
61//! never concurrently, never reentrantly — in this order:
62//!
63//! 1. `on_stream_info` — at most once, after every encoder finalized its
64//! parameters and **before any packet**. The video configuration is
65//! already a valid avcC record here.
66//! 2. `on_packet` — zero or more times.
67//! 3. `on_end` **or** `on_delivery_error` — at most one of them, at most
68//! once:
69//! * `on_end` fires only when every output stream reached a recognized
70//! terminal state (natural encoder EOF, or configured truncation such as
71//! `set_recording_time_us` / `set_shortest`), everything was delivered,
72//! and the whole job settled without an error: the delivery thread
73//! first waits for every other job worker to finish (including
74//! container outputs' teardown), then decides on one fresh
75//! status/result read — the linearization point. Sibling packet-sink
76//! workers are the one exception to that wait: they are only
77//! guaranteed settled by then (errors recorded, encoders joined,
78//! contexts freed) — their terminal callbacks and capture drops may
79//! still be running concurrently. An `abort()` that lands after the
80//! status read is indistinguishable from one after the callback.
81//! * `on_delivery_error` fires when delivery stopped because of a
82//! strict-tier violation or a failing callback, or when the job failed
83//! elsewhere — whether that failure landed after this sink delivered
84//! everything or truncated its delivery. Cancellation is silent only
85//! when it interrupts delivery: a `stop()` that lands after this sink
86//! fully drained still delivers `on_end`. Cancellation also takes
87//! precedence over a failure it races with: a sink that observes the
88//! published termination — `stop()`, `abort()`, or dropping the
89//! running scheduler (its guard publishes the same status) — and
90//! cancels its delivery cooperatively before a sibling's error is
91//! recorded stays silent — no `on_delivery_error`. The late error is
92//! still recorded first-error-wins as the job result, and the `stop()`
93//! call that drove the race returns it once every worker has settled;
94//! after `abort()`, which returns nothing, or a drop, which discards
95//! the result with the scheduler, it goes unobserved. When the failure
96//! was recorded OUTSIDE this sink's delivery path, an optional
97//! observer — the builder callback
98//! [`PacketSinkBuilder::on_job_failed`](crate::packet_sink::PacketSinkBuilder::on_job_failed)
99//! or the handler override
100//! [`PacketSinkHandler::on_job_failed`](crate::packet_sink::PacketSinkHandler::on_job_failed)
101//! — receives a structured
102//! [`JobFailureSummary`](crate::packet_sink::JobFailureSummary)
103//! immediately before that synthesized `JobFailed` dispatch.
104//!
105//! # Timestamp and ordering
106//!
107//! Timestamps are per-stream: within one stream, dts is strictly increasing
108//! and `pts >= dts`. **No cross-stream interleaving order is promised** —
109//! audio and video packets arrive in worker order, and a consumer must route
110//! by [`PacketView::stream_index`](crate::packet_sink::PacketView::stream_index)
111//! rather than assume global ordering. All streams share one time origin (see
112//! [`PacketView::applied_offset`](crate::packet_sink::PacketView::applied_offset)). A
113//! packet that violates the strict contract (including a mid-stream
114//! configuration change) fails the job typed and is **never delivered**.
115//!
116//! # Failure and panic
117//!
118//! The scheduler result returned by `wait()`/`stop()` is **authoritative**;
119//! terminal callbacks are a convenience with deliberately narrower coverage.
120//! In these cases **no terminal sink callback fires at all**:
121//!
122//! * initial configuration failure (missing/malformed extradata, whitelist
123//! violations) — the job fails before any callback runs;
124//! * cancellation (`stop()` with packets still in flight, `abort()`);
125//! * a panicking DELIVERY callback (`on_stream_info`, `on_packet`) — the job
126//! fails with a worker-panic error and no further sink callback is
127//! invoked. The consumer's captures are still destroyed at the defined
128//! teardown point, each callback box under its own containment, so a
129//! panicking capture destructor cannot escalate the delivery-phase panic
130//! into a process abort.
131//!
132//! Single carve-out — the post-settlement region: once the job has settled
133//! and the terminal decision is made, everything that remains on the
134//! delivery thread is user code (the terminal callback itself, then the
135//! destruction of the consumer's captures at the defined teardown point).
136//! A panic ANYWHERE in that region — `on_end`, `on_delivery_error`, or a
137//! capture's `Drop` — is caught, logged at error level, and does NOT change
138//! the already-settled job result (a delivered or decided `on_end` still
139//! yields `wait() == Ok`, and a failing job keeps its original error).
140//!
141//! That containment is **per callback box** (per handler box for
142//! [`PacketSinkHandler`](crate::packet_sink::PacketSinkHandler)), and once
143//! the stream configuration has been collected the same per-box boundary
144//! guards capture teardown along the whole delivery path — including a
145//! delivery-phase unwind. A panic
146//! thrown by a callback, or by ONE
147//! destructor — a captured value's, a stashed error source's, or a
148//! `panic_any` payload's — is contained, and the crate keeps every such
149//! unwind single: each box is destroyed under its own catch, and the
150//! stashed delivery error stays in the worker's custody while
151//! `on_delivery_error` borrows it. The boundary is Rust's own unwind
152//! semantics: when one capture's destructor panics, the remaining captures
153//! OF THAT SAME BOX are dropped by the unwind itself — an erased box
154//! destroys its captures as one indivisible drop-glue call that nothing
155//! outside the box can decompose — so a SECOND panicking destructor there
156//! is a panic-during-unwind process abort, exactly as in any Rust struct
157//! whose field destructors both panic. Keep the destructors of values
158//! captured together panic-free relative to one another.
159//!
160//! # Backpressure: callbacks block the pipeline
161//!
162//! **The callbacks run on the delivery thread. A slow `on_packet` blocks that
163//! thread, the bounded packet queue behind it fills, and the encoders stall —
164//! exactly the backpressure a slow container write exerts today.** No packet
165//! is ever silently dropped. If you need decoupling, copy the borrowed data
166//! out (it is only valid during the callback) and queue it yourself, or use
167//! [`PacketSink::channel`](crate::packet_sink::PacketSink::channel), which
168//! does that copy for you and blocks the
169//! pipeline only while its bounded channel is full. The channel's blocking
170//! send observes job cancellation, so `stop()` terminates even with a full,
171//! undrained channel.
172//!
173//! # Example
174//!
175//! ```rust,no_run
176//! use ez_ffmpeg::packet_sink::PacketSink;
177//! use ez_ffmpeg::{FfmpegContext, Output};
178//!
179//! fn main() -> Result<(), Box<dyn std::error::Error>> {
180//! let sink = PacketSink::builder(|packet| {
181//! println!(
182//! "stream {} pts {} ({} bytes)",
183//! packet.stream_index(),
184//! packet.pts(),
185//! packet.data().len()
186//! );
187//! Ok(())
188//! })
189//! .on_end(|| println!("done"))
190//! .build();
191//!
192//! FfmpegContext::builder()
193//! .input("input.mp4")
194//! .output(Output::from(sink).set_video_codec("libx264"))
195//! .build()?
196//! .start()?
197//! .wait()?;
198//! Ok(())
199//! }
200//! ```
201
202use crate::core::scheduler::ffmpeg_scheduler::{is_stopping, FfmpegScheduler, Running};
203use crate::core::scheduler::owned_run_iter::OwnedRunIter;
204pub use crate::error::PacketSinkError;
205use ffmpeg_sys_next::{AVCodecID, AVMediaType, AVRational};
206use std::num::NonZeroUsize;
207use std::sync::atomic::{AtomicUsize, Ordering};
208use std::sync::{Arc, OnceLock};
209use std::time::Duration;
210
211#[cfg(test)]
212mod bench_nal_scan;
213pub(crate) mod codec;
214mod job_failure;
215pub(crate) mod nal_framing;
216pub(crate) mod registry;
217pub(crate) mod side_data;
218pub(crate) mod strict;
219pub(crate) mod timeline;
220
221pub use job_failure::{JobFailureKind, JobFailureSummary};
222
223/// Delivery tier of a packet sink. Only [`Strict`](PacketSinkTier::Strict)
224/// exists in v1; the enum is `#[non_exhaustive]` so later tiers (generic
225/// passthrough, HEVC, Annex-B) are additive.
226///
227/// The strict construction paths ([`PacketSink::builder`],
228/// [`PacketSink::from_handler`], [`PacketSink::channel`]) do NOT take a tier:
229/// they are strict-tier by definition, because their callback bundle is typed
230/// to the strict [`PacketView`]/[`PacketStreamInfo`] contract (mandatory
231/// `i64` timestamps and durations). A future tier arrives as its own
232/// constructor with its own view/config/callback types — never by routing a
233/// different tier through the strict bundle.
234#[non_exhaustive]
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum PacketSinkTier {
237 /// WebCodecs-aligned strict tier: avcC H.264 (registry-verified
238 /// wrappers: libx264, h264_nvenc, h264_videotoolbox, libopenh264;
239 /// VideoToolbox explicit non-zero `bf` / `max_b_frames` is rejected at
240 /// build — `max_b_frames` is a policy admission key, not an FFmpeg
241 /// alias of `bf`, and is never applied to the encoder) + AAC.
242 #[default]
243 Strict,
244}
245
246/// Why a callback rejected delivery. Carries a message and an optional
247/// source error, both preserved on the job result via
248/// [`PacketSinkError::PacketCallbackFailed`].
249#[derive(Debug, Clone)]
250pub struct PacketCallbackError {
251 message: String,
252 source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
253 pub(crate) kind: CallbackFailureKind,
254}
255
256/// Internal classification of a callback failure.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub(crate) enum CallbackFailureKind {
259 /// A consumer-reported failure: the job stops with a typed error.
260 Failure,
261 /// The owned-channel receiver is gone: the job stops with
262 /// [`PacketSinkError::ChannelDisconnected`].
263 Disconnected,
264 /// The job is already stopping WITHOUT a recorded error (explicit
265 /// `stop()`/`abort()`) and a blocking send bailed out cooperatively:
266 /// NOT an error (mirrors the worker's stop observation).
267 Cancelled,
268 /// The job is stopping because some worker recorded a FAILURE while a
269 /// blocking send was parked: delivery is truncated by that job failure
270 /// (the terminal reports it as `JobFailed`), not cancelled.
271 JobStopped,
272}
273
274impl PacketCallbackError {
275 /// A failure described by a message.
276 pub fn new(message: impl Into<String>) -> Self {
277 Self {
278 message: message.into(),
279 source: None,
280 kind: CallbackFailureKind::Failure,
281 }
282 }
283
284 /// A failure wrapping a source error (preserved on the job result).
285 pub fn with_source(
286 message: impl Into<String>,
287 source: impl std::error::Error + Send + Sync + 'static,
288 ) -> Self {
289 Self {
290 message: message.into(),
291 source: Some(Arc::new(source)),
292 kind: CallbackFailureKind::Failure,
293 }
294 }
295
296 pub(crate) fn disconnected() -> Self {
297 Self {
298 message: "packet-sink channel receiver dropped".to_string(),
299 source: None,
300 kind: CallbackFailureKind::Disconnected,
301 }
302 }
303
304 pub(crate) fn job_stopped() -> Self {
305 Self {
306 message: "job failed elsewhere; blocking send abandoned".to_string(),
307 source: None,
308 kind: CallbackFailureKind::JobStopped,
309 }
310 }
311
312 pub(crate) fn cancelled() -> Self {
313 Self {
314 message: "job stopping; blocking send cancelled".to_string(),
315 source: None,
316 kind: CallbackFailureKind::Cancelled,
317 }
318 }
319}
320
321impl std::fmt::Display for PacketCallbackError {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 f.write_str(&self.message)
324 }
325}
326
327impl std::error::Error for PacketCallbackError {
328 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
329 self.source
330 .as_ref()
331 .map(|s| s.as_ref() as &(dyn std::error::Error + 'static))
332 }
333}
334
335/// What every fallible sink callback returns: `Ok(())` continues delivery, an
336/// error stops the job with a typed, source-preserving [`PacketSinkError`].
337pub type PacketCallbackResult = Result<(), PacketCallbackError>;
338
339/// A single stateful packet consumer. All methods run serially on the one
340/// delivery thread (never concurrently, never reentrantly), so `&mut self`
341/// state needs no locking. This is the strict-tier handler shape; see the
342/// [module docs](self) for the callback order and backpressure contract.
343///
344/// Teardown panic containment is per handler box: a panic from a method,
345/// or from ONE of the handler's fields' destructors, is contained; two
346/// fields whose destructors both panic compose into a panic-during-unwind
347/// process abort, as in any Rust struct — see "Failure and panic" in the
348/// [module docs](self).
349pub trait PacketSinkHandler: Send + 'static {
350 /// One-time stream configuration, before any packet.
351 fn on_stream_info(&mut self, _streams: &[PacketStreamInfo]) -> PacketCallbackResult {
352 Ok(())
353 }
354
355 /// One delivered packet; the borrowed view is valid only for this call.
356 fn on_packet(&mut self, packet: &PacketView<'_>) -> PacketCallbackResult;
357
358 /// Terminal success (see the module docs for the exact gate). A panic
359 /// here is contained and cannot change the settled job result.
360 fn on_end(&mut self) {}
361
362 /// Optional structured observer for a job that failed OUTSIDE this
363 /// sink's delivery path.
364 ///
365 /// Fires ONLY on the synthesized-JobFailed path: the job failed
366 /// elsewhere (a sibling output, an upstream demuxer, decoder, filter or
367 /// encoder) while this sink's own delivery was clean, whether that
368 /// failure landed after this sink drained or truncated its delivery. It
369 /// does NOT fire for this sink's own delivery-path errors (strict-tier
370 /// violations, failing callbacks), nor for cancellation, aborts, or
371 /// initial configuration failures.
372 ///
373 /// When it fires, it fires exactly once, immediately BEFORE the
374 /// matching `on_delivery_error(&PacketSinkError::JobFailed { .. })` —
375 /// same delivery thread, same terminal slot — and the summary's
376 /// [`message`](JobFailureSummary::message) is byte-identical to that
377 /// `JobFailed` message. `wait()`/`stop()` keep returning the original
378 /// job error. A panic here is contained per handler box and can neither
379 /// skip the terminal dispatch that follows nor change the settled job
380 /// result. The default implementation does nothing, so existing
381 /// handlers keep their exact behavior.
382 fn on_job_failed(&mut self, _summary: &JobFailureSummary) {}
383
384 /// Terminal failure. For delivery-path errors (strict-tier violations,
385 /// failing callbacks) the same error is also reported as the job result
386 /// unless an earlier failure already settled it (job settlement is
387 /// first-error-wins). When the
388 /// JOB failed elsewhere (after this sink drained or truncating its
389 /// delivery), the callback receives a synthesized
390 /// [`PacketSinkError::JobFailed`] summarizing that failure, while
391 /// `wait()`/`stop()` keep the original error (an overridden
392 /// [`on_job_failed`](Self::on_job_failed) receives the structured
393 /// summary immediately before this dispatch).
394 fn on_delivery_error(&mut self, _error: &PacketSinkError) {}
395}
396
397/// Per-stream video configuration delivered via `on_stream_info` —
398/// everything a WebCodecs `VideoDecoder` / fMP4 packager needs, precomputed.
399#[non_exhaustive]
400#[derive(Debug, Clone)]
401pub struct VideoPacketConfig {
402 pub(crate) stream_index: usize,
403 pub(crate) codec_id: AVCodecID,
404 pub(crate) codec_string: String,
405 pub(crate) profile: u8,
406 pub(crate) compatibility: u8,
407 pub(crate) level: u8,
408 pub(crate) codec_config: Vec<u8>,
409 pub(crate) time_base: AVRational,
410 pub(crate) width: i32,
411 pub(crate) height: i32,
412 pub(crate) sample_aspect_ratio: Option<AVRational>,
413 pub(crate) frame_rate: Option<AVRational>,
414}
415
416impl VideoPacketConfig {
417 /// Output stream index; matches [`PacketView::stream_index`].
418 pub fn stream_index(&self) -> usize {
419 self.stream_index
420 }
421
422 /// FFmpeg codec id (`AV_CODEC_ID_H264` in the strict tier).
423 pub fn codec_id(&self) -> AVCodecID {
424 self.codec_id
425 }
426
427 /// RFC 6381 codec string (`"avc1.PPCCLL"`), suitable as the WebCodecs
428 /// `codec` value.
429 pub fn codec_string(&self) -> &str {
430 &self.codec_string
431 }
432
433 /// H.264 `profile_idc` (the avcC `AVCProfileIndication`; e.g. 66 =
434 /// Baseline, 77 = Main, 100 = High). Same source as
435 /// [`codec_string`](Self::codec_string).
436 pub fn profile(&self) -> u8 {
437 self.profile
438 }
439
440 /// The avcC `profile_compatibility` byte (constraint-set flags).
441 pub fn compatibility(&self) -> u8 {
442 self.compatibility
443 }
444
445 /// H.264 `level_idc` (the avcC `AVCLevelIndication`; e.g. 30 = level
446 /// 3.0, 0x1F = level 3.1).
447 pub fn level(&self) -> u8 {
448 self.level
449 }
450
451 /// The `AVCDecoderConfigurationRecord` (avcC), suitable as the WebCodecs
452 /// `description`.
453 pub fn codec_config(&self) -> &[u8] {
454 &self.codec_config
455 }
456
457 /// FFmpeg-oriented alias of [`codec_config`](Self::codec_config).
458 pub fn extradata(&self) -> &[u8] {
459 &self.codec_config
460 }
461
462 /// Time base every timestamp of this stream is expressed in (the encoder
463 /// time base, passed through verbatim).
464 pub fn time_base(&self) -> AVRational {
465 self.time_base
466 }
467
468 /// Coded width in pixels.
469 pub fn width(&self) -> i32 {
470 self.width
471 }
472
473 /// Coded height in pixels.
474 pub fn height(&self) -> i32 {
475 self.height
476 }
477
478 /// Sample aspect ratio, when known.
479 pub fn sample_aspect_ratio(&self) -> Option<AVRational> {
480 self.sample_aspect_ratio
481 }
482
483 /// Nominal frame rate. `None` when the pipeline did not pin one (VFR
484 /// sources, and CFR jobs without an explicit output rate).
485 pub fn frame_rate(&self) -> Option<AVRational> {
486 self.frame_rate
487 }
488}
489
490/// Per-stream audio configuration delivered via `on_stream_info`.
491#[non_exhaustive]
492#[derive(Debug, Clone)]
493pub struct AudioPacketConfig {
494 pub(crate) stream_index: usize,
495 pub(crate) codec_id: AVCodecID,
496 pub(crate) codec_string: String,
497 pub(crate) codec_config: Vec<u8>,
498 pub(crate) time_base: AVRational,
499 pub(crate) sample_rate: i32,
500 pub(crate) channels: i32,
501 pub(crate) channel_layout: String,
502}
503
504impl AudioPacketConfig {
505 /// Output stream index; matches [`PacketView::stream_index`].
506 pub fn stream_index(&self) -> usize {
507 self.stream_index
508 }
509
510 /// FFmpeg codec id (`AV_CODEC_ID_AAC` in the strict tier).
511 pub fn codec_id(&self) -> AVCodecID {
512 self.codec_id
513 }
514
515 /// RFC 6381 codec string (`"mp4a.40.X"`, X = audio object type).
516 pub fn codec_string(&self) -> &str {
517 &self.codec_string
518 }
519
520 /// The `AudioSpecificConfig`, suitable as the WebCodecs `description`.
521 pub fn codec_config(&self) -> &[u8] {
522 &self.codec_config
523 }
524
525 /// FFmpeg-oriented alias of [`codec_config`](Self::codec_config).
526 pub fn extradata(&self) -> &[u8] {
527 &self.codec_config
528 }
529
530 /// Time base every timestamp of this stream is expressed in.
531 pub fn time_base(&self) -> AVRational {
532 self.time_base
533 }
534
535 /// Sample rate in Hz.
536 pub fn sample_rate(&self) -> i32 {
537 self.sample_rate
538 }
539
540 /// Channel count.
541 pub fn channels(&self) -> i32 {
542 self.channels
543 }
544
545 /// FFmpeg channel-layout description (e.g. `"stereo"`, `"5.1"`).
546 pub fn channel_layout(&self) -> &str {
547 &self.channel_layout
548 }
549}
550
551/// Per-stream configuration delivered once via `on_stream_info`, typed by
552/// media kind (mirrors the crate's `StreamInfo` shape).
553#[non_exhaustive]
554#[derive(Debug, Clone)]
555pub enum PacketStreamInfo {
556 /// H.264 stream configuration: avcC record, RFC 6381 codec string,
557 /// profile/level, dimensions, time base, frame rate.
558 Video(VideoPacketConfig),
559 /// AAC stream configuration: AudioSpecificConfig, RFC 6381 codec string,
560 /// time base, sample rate, channel layout.
561 Audio(AudioPacketConfig),
562}
563
564impl PacketStreamInfo {
565 /// Output stream index; matches [`PacketView::stream_index`].
566 pub fn stream_index(&self) -> usize {
567 match self {
568 PacketStreamInfo::Video(v) => v.stream_index,
569 PacketStreamInfo::Audio(a) => a.stream_index,
570 }
571 }
572
573 /// Media type of the stream.
574 pub fn media_type(&self) -> AVMediaType {
575 match self {
576 PacketStreamInfo::Video(_) => AVMediaType::AVMEDIA_TYPE_VIDEO,
577 PacketStreamInfo::Audio(_) => AVMediaType::AVMEDIA_TYPE_AUDIO,
578 }
579 }
580
581 /// FFmpeg codec id.
582 pub fn codec_id(&self) -> AVCodecID {
583 match self {
584 PacketStreamInfo::Video(v) => v.codec_id,
585 PacketStreamInfo::Audio(a) => a.codec_id,
586 }
587 }
588
589 /// RFC 6381 codec string (`"avc1.PPCCLL"` / `"mp4a.40.X"`).
590 pub fn codec_string(&self) -> &str {
591 match self {
592 PacketStreamInfo::Video(v) => &v.codec_string,
593 PacketStreamInfo::Audio(a) => &a.codec_string,
594 }
595 }
596
597 /// Codec configuration record (avcC / AudioSpecificConfig).
598 pub fn codec_config(&self) -> &[u8] {
599 match self {
600 PacketStreamInfo::Video(v) => &v.codec_config,
601 PacketStreamInfo::Audio(a) => &a.codec_config,
602 }
603 }
604
605 /// FFmpeg-oriented alias of [`codec_config`](Self::codec_config).
606 pub fn extradata(&self) -> &[u8] {
607 self.codec_config()
608 }
609
610 /// Time base every timestamp of this stream is expressed in.
611 pub fn time_base(&self) -> AVRational {
612 match self {
613 PacketStreamInfo::Video(v) => v.time_base,
614 PacketStreamInfo::Audio(a) => a.time_base,
615 }
616 }
617
618 /// The video configuration, when this is a video stream.
619 pub fn video(&self) -> Option<&VideoPacketConfig> {
620 match self {
621 PacketStreamInfo::Video(v) => Some(v),
622 _ => None,
623 }
624 }
625
626 /// The audio configuration, when this is an audio stream.
627 pub fn audio(&self) -> Option<&AudioPacketConfig> {
628 match self {
629 PacketStreamInfo::Audio(a) => Some(a),
630 _ => None,
631 }
632 }
633}
634
635/// Converts stream ticks to microseconds (exact rescale, round-nearest).
636fn ticks_to_us(ticks: i64, time_base: AVRational) -> i64 {
637 // SAFETY: pure integer arithmetic; every stream time base was validated
638 // positive at collection, and the target rational is a constant.
639 unsafe {
640 ffmpeg_sys_next::av_rescale_q(
641 ticks,
642 time_base,
643 AVRational {
644 num: 1,
645 den: 1_000_000,
646 },
647 )
648 }
649}
650
651/// Borrowed view of one delivered packet (strict tier).
652///
653/// The view — including [`data`](Self::data) — is valid **only during the
654/// `on_packet` callback**; the underlying packet is recycled as soon as the
655/// callback returns. Copy out what you keep.
656#[non_exhaustive]
657#[derive(Debug)]
658pub struct PacketView<'a> {
659 pub(crate) stream_index: usize,
660 pub(crate) pts: i64,
661 pub(crate) dts: i64,
662 pub(crate) duration: i64,
663 pub(crate) time_base: AVRational,
664 pub(crate) is_key: bool,
665 pub(crate) applied_offset: i64,
666 pub(crate) data: &'a [u8],
667}
668
669impl<'a> PacketView<'a> {
670 /// Output stream index (matches the `on_stream_info` entries).
671 pub fn stream_index(&self) -> usize {
672 self.stream_index
673 }
674
675 /// Presentation timestamp in [`time_base`](Self::time_base) units, on the
676 /// shared zero-based timeline (see
677 /// [`applied_offset`](Self::applied_offset)).
678 pub fn pts(&self) -> i64 {
679 self.pts
680 }
681
682 /// Decode timestamp in [`time_base`](Self::time_base) units, strictly
683 /// increasing per stream. The strict tier also requires `pts >= dts` on
684 /// every delivered packet: B-frame reordering that would invert that
685 /// fails the job before delivery. May be negative on non-anchor streams
686 /// (a stream whose timeline starts earlier than the anchor stream keeps
687 /// its true relative offset).
688 pub fn dts(&self) -> i64 {
689 self.dts
690 }
691
692 /// Packet duration in [`time_base`](Self::time_base) units. Always
693 /// positive in the strict tier: the encoder's duration is passed through;
694 /// when absent it is derived (video: one CFR frame interval; audio: the
695 /// codec frame size). A packet whose duration cannot be derived fails the
696 /// job before delivery — this field is never a guess of zero.
697 pub fn duration(&self) -> i64 {
698 self.duration
699 }
700
701 /// Time base of this stream (identical to the stream's
702 /// [`PacketStreamInfo::time_base`]).
703 pub fn time_base(&self) -> AVRational {
704 self.time_base
705 }
706
707 /// [`pts`](Self::pts) in microseconds (exact rescale of the ticks).
708 pub fn pts_us(&self) -> i64 {
709 ticks_to_us(self.pts, self.time_base)
710 }
711
712 /// [`dts`](Self::dts) in microseconds.
713 pub fn dts_us(&self) -> i64 {
714 ticks_to_us(self.dts, self.time_base)
715 }
716
717 /// [`duration`](Self::duration) in microseconds.
718 pub fn duration_us(&self) -> i64 {
719 ticks_to_us(self.duration, self.time_base)
720 }
721
722 /// [`applied_offset`](Self::applied_offset) in microseconds.
723 pub fn applied_offset_us(&self) -> i64 {
724 ticks_to_us(self.applied_offset, self.time_base)
725 }
726
727 /// Whether this packet is a fresh-decoder-safe random access point.
728 ///
729 /// For H.264 this is true **iff the access unit contains an IDR NAL
730 /// unit** — deliberately not the encoder's raw `AV_PKT_FLAG_KEY`: with
731 /// open-GOP encoding, encoders flag non-IDR recovery points as key
732 /// frames, and feeding such a packet to a fresh decoder (the WebCodecs
733 /// `"key"` contract) is not safe. Audio packets are always key.
734 pub fn is_key(&self) -> bool {
735 self.is_key
736 }
737
738 /// The per-stream offset that was subtracted from `pts`/`dts` to move
739 /// this stream onto the shared zero-based timeline, in this stream's
740 /// [`time_base`](Self::time_base) units.
741 ///
742 /// All streams share a single origin: the `(dts, time_base)` of the first
743 /// delivered packet of the job. The anchor stream therefore starts at
744 /// dts 0; other streams keep their true audio/video offset (which may be
745 /// negative). `original_ts = delivered_ts + applied_offset` recovers the
746 /// encoder timeline exactly (cross-time-base rounding is at most one tick
747 /// per stream).
748 pub fn applied_offset(&self) -> i64 {
749 self.applied_offset
750 }
751
752 /// The packet payload. H.264: 4-byte length-prefixed (AVCC), parameter
753 /// sets carried out-of-band in the stream configuration. The Trusted
754 /// path does **not** inspect each payload to prove one complete access
755 /// unit; that property is assumed from the admitted encoder wrappers.
756 /// AAC: one raw AAC frame.
757 pub fn data(&self) -> &'a [u8] {
758 self.data
759 }
760}
761
762pub(crate) type StreamInfoFn = Box<dyn FnMut(&[PacketStreamInfo]) -> PacketCallbackResult + Send>;
763pub(crate) type PacketFn = Box<dyn for<'a> FnMut(&PacketView<'a>) -> PacketCallbackResult + Send>;
764pub(crate) type EndFn = Box<dyn FnMut() + Send>;
765pub(crate) type JobFailedFn = Box<dyn FnMut(&JobFailureSummary) + Send>;
766pub(crate) type DeliveryErrorFn = Box<dyn FnMut(&PacketSinkError) + Send>;
767
768/// How the sink dispatches callbacks: independent closures, or one stateful
769/// handler. Either way every call runs serially on the delivery thread.
770enum SinkDispatch {
771 Closures {
772 on_stream_info: Option<StreamInfoFn>,
773 on_packet: PacketFn,
774 on_end: Option<EndFn>,
775 on_job_failed: Option<JobFailedFn>,
776 on_delivery_error: Option<DeliveryErrorFn>,
777 },
778 Handler(Box<dyn PacketSinkHandler>),
779}
780
781/// What the owned-channel adapter observes about the job while a bounded
782/// send is blocked: the scheduler status (has the job stopped?) and the
783/// scheduler result (did it stop because some worker FAILED?). Published by
784/// the worker at collection time.
785pub(crate) struct JobStopObservables {
786 pub(crate) status: Arc<AtomicUsize>,
787 pub(crate) result: Arc<std::sync::Mutex<Option<crate::error::Result<()>>>>,
788}
789
790/// Slot the owned-channel adapter uses to observe job cancellation: see
791/// [`JobStopObservables`]. One slot is allocated per [`PacketSink::channel`]
792/// call and shared by the sink's callbacks and its receiver, so the Arc's
793/// pointer identity doubles as the pair's run token: the muxer keeps a clone
794/// and [`PacketSinkReceiver::into_events`] matches its own clone against the
795/// scheduler's job to reject a cross-wired scheduler.
796pub(crate) type CancellationSlot = Arc<OnceLock<JobStopObservables>>;
797
798/// The consumer bundle handed to `Output::from(sink)` /
799/// [`Output::new_by_packet_sink`](crate::Output::new_by_packet_sink).
800///
801/// Build one with [`PacketSink::builder`] (closures),
802/// [`PacketSink::from_handler`] (one stateful consumer) or
803/// [`PacketSink::channel`] (owned events over a bounded channel). All
804/// construction paths produce a **strict-tier** sink; see the
805/// [module docs](self) for the callback order and the **blocking
806/// backpressure** contract.
807///
808/// **Experimental:** new in 0.15; the surface may still be refined.
809pub struct PacketSink {
810 pub(crate) tier: PacketSinkTier,
811 dispatch: SinkDispatch,
812 /// `Some` only for channel-adapter sinks (see [`CancellationSlot`]).
813 pub(crate) cancellation: Option<CancellationSlot>,
814}
815
816impl std::fmt::Debug for PacketSink {
817 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818 f.debug_struct("PacketSink")
819 .field("tier", &self.tier)
820 .finish_non_exhaustive()
821 }
822}
823
824impl PacketSink {
825 /// The delivery tier this sink was built for.
826 ///
827 /// Every v1 construction path produces [`PacketSinkTier::Strict`], so
828 /// today this always returns `Strict`; the accessor exists so consumers
829 /// that route or log sinks can branch on the tier once additional tiers
830 /// land, instead of inferring it from which constructor was used.
831 pub fn tier(&self) -> PacketSinkTier {
832 self.tier
833 }
834
835 /// Starts building a strict-tier sink around the required packet
836 /// consumer. `on_stream_info`, `on_end` and `on_delivery_error` are
837 /// optional extras on the returned builder — but a sink cannot exist
838 /// without a packet consumer (a job that encodes into nothing is a
839 /// configuration mistake, not a default; use [`PacketSink::discard`]
840 /// when discarding is genuinely intended).
841 ///
842 /// Teardown panic containment is per callback box: a panic from the
843 /// closure, or from ONE captured value's destructor, is contained; two
844 /// captures of this same closure whose destructors both panic compose
845 /// into a panic-during-unwind process abort, as in any Rust struct —
846 /// see "Failure and panic" in the [module docs](self).
847 pub fn builder<F>(on_packet: F) -> PacketSinkBuilder
848 where
849 F: for<'a> FnMut(&PacketView<'a>) -> PacketCallbackResult + Send + 'static,
850 {
851 PacketSinkBuilder {
852 tier: PacketSinkTier::Strict,
853 on_stream_info: None,
854 on_packet: Box::new(on_packet),
855 on_end: None,
856 on_job_failed: None,
857 on_delivery_error: None,
858 }
859 }
860
861 /// A sink that deliberately discards every packet (accepting them all).
862 /// Exists so intent is explicit — mainly for validation-only jobs and
863 /// tests.
864 pub fn discard() -> PacketSink {
865 PacketSink::builder(|_| Ok(())).build()
866 }
867
868 /// Builds a strict-tier sink around one stateful [`PacketSinkHandler`] —
869 /// the natural shape for consumers whose stream-info/packet/terminal
870 /// handling shares state (packagers, senders); callbacks are serial, so
871 /// the handler needs no locking.
872 ///
873 /// Teardown panic containment is per handler box: a panic from a
874 /// handler method, or from ONE of the handler's fields' destructors, is
875 /// contained; two fields of this same handler whose destructors both
876 /// panic compose into a panic-during-unwind process abort, as in any
877 /// Rust struct — see "Failure and panic" in the [module docs](self).
878 pub fn from_handler<H: PacketSinkHandler>(handler: H) -> PacketSink {
879 PacketSink {
880 tier: PacketSinkTier::Strict,
881 dispatch: SinkDispatch::Handler(Box::new(handler)),
882 cancellation: None,
883 }
884 }
885
886 /// Builds a strict-tier sink that forwards everything over a **bounded**
887 /// channel of owned events, for consumers that want packets on their own
888 /// thread.
889 ///
890 /// Every payload is copied once into an owned [`EncodedPacket`] (one
891 /// additional adapter copy on top of any Annex-B normalization). The
892 /// channel preserves the callback contract: when it is full, the sending
893 /// callback **blocks the pipeline** until the consumer catches up — no
894 /// packet is dropped. **Drain the receiver concurrently** (its own
895 /// thread, or [`PacketSinkReceiver::into_events`]); draining only after
896 /// `wait()` deadlocks as soon as the channel fills, because `wait()`
897 /// needs the blocked worker to finish. The blocking send observes job
898 /// cancellation, so `stop()`/`abort()` (or a job failing elsewhere)
899 /// terminates even with a full, undrained channel. Dropping the receiver
900 /// cancels the job with [`PacketSinkError::ChannelDisconnected`].
901 ///
902 /// Terminal `End`/`Error` events — and the `JobFailure` summary queued
903 /// immediately before a job-failure `Error` — are delivered best-effort
904 /// ON THE RAW CHANNEL: the send behind them must not block teardown, so
905 /// a consumer that is full at that instant — stalled forever or merely a
906 /// few events behind — loses them, and sender disconnection
907 /// (`Disconnected` on the receiver) is the authoritative end-of-events
908 /// signal. [`PacketSinkReceiver::into_events`] restores the
909 /// deterministic ending on top: a stream without a terminal `Err` always
910 /// ends with `End`.
911 pub fn channel(capacity: NonZeroUsize) -> (PacketSink, PacketSinkReceiver) {
912 let (tx, rx) = crossbeam_channel::bounded::<PacketSinkEvent>(capacity.get());
913 let cancellation: CancellationSlot = Arc::new(OnceLock::new());
914 let info_tx = tx.clone();
915 let info_cancel = cancellation.clone();
916 let pkt_tx = tx.clone();
917 let pkt_cancel = cancellation.clone();
918 let end_tx = tx.clone();
919 let job_failed_tx = tx.clone();
920 let err_tx = tx;
921 let mut sink = PacketSink::builder(move |packet: &PacketView<'_>| {
922 send_with_cancellation(
923 &pkt_tx,
924 &pkt_cancel,
925 PacketSinkEvent::Packet(EncodedPacket::from_view(packet)),
926 )
927 })
928 .on_stream_info(move |infos: &[PacketStreamInfo]| {
929 send_with_cancellation(
930 &info_tx,
931 &info_cancel,
932 PacketSinkEvent::StreamInfo(infos.to_vec()),
933 )
934 })
935 .on_end(move || {
936 // Best-effort terminal event: the job is already in its terminal
937 // state here (a cancellation-aware blocking send would be
938 // indistinguishable from try_send), and sender disconnection is
939 // the authoritative signal.
940 let _ = end_tx.try_send(PacketSinkEvent::End);
941 })
942 .on_job_failed(move |summary: &JobFailureSummary| {
943 // Best-effort like the terminal events it precedes, with one
944 // extra guard: the summary must never consume the LAST free
945 // slot — the Error(JobFailed) event behind it has first claim
946 // on that capacity. A consumer that drained the channel before
947 // the terminal (the documented way to catch the best-effort
948 // terminal on a small adapter) was guaranteed the Error event
949 // before the summary existed and must stay guaranteed it. Only
950 // this thread sends, and the consumer can only FREE slots, so
951 // a two-free-slots check here cannot be raced into starving
952 // the Error send that follows.
953 let free = job_failed_tx
954 .capacity()
955 .unwrap_or(usize::MAX)
956 .saturating_sub(job_failed_tx.len());
957 if free >= 2 {
958 let _ = job_failed_tx.try_send(PacketSinkEvent::JobFailure(summary.clone()));
959 }
960 })
961 .on_delivery_error(move |e: &PacketSinkError| {
962 let _ = err_tx.try_send(PacketSinkEvent::Error(e.clone()));
963 })
964 .build();
965 sink.cancellation = Some(cancellation.clone());
966 (
967 sink,
968 PacketSinkReceiver {
969 inner: rx,
970 token: cancellation,
971 },
972 )
973 }
974
975 // ---- crate-internal dispatch (serial, delivery thread only) ----
976
977 pub(crate) fn dispatch_stream_info(
978 &mut self,
979 infos: &[PacketStreamInfo],
980 ) -> PacketCallbackResult {
981 match &mut self.dispatch {
982 SinkDispatch::Closures { on_stream_info, .. } => match on_stream_info {
983 Some(f) => f(infos),
984 None => Ok(()),
985 },
986 SinkDispatch::Handler(h) => h.on_stream_info(infos),
987 }
988 }
989
990 pub(crate) fn dispatch_packet(&mut self, packet: &PacketView<'_>) -> PacketCallbackResult {
991 match &mut self.dispatch {
992 SinkDispatch::Closures { on_packet, .. } => on_packet(packet),
993 SinkDispatch::Handler(h) => h.on_packet(packet),
994 }
995 }
996
997 pub(crate) fn dispatch_end(&mut self) {
998 match &mut self.dispatch {
999 SinkDispatch::Closures { on_end, .. } => {
1000 if let Some(f) = on_end {
1001 f()
1002 }
1003 }
1004 SinkDispatch::Handler(h) => h.on_end(),
1005 }
1006 }
1007
1008 /// Dispatches the structured job-failure summary to the registered
1009 /// observer: the builder's `on_job_failed` callback, or the handler's
1010 /// [`PacketSinkHandler::on_job_failed`] (default-empty). The caller
1011 /// (the worker's terminal slot) wraps this call in its own panic
1012 /// containment so a panicking observer can never skip the
1013 /// `on_delivery_error` dispatch that follows it.
1014 pub(crate) fn dispatch_job_failed(&mut self, summary: &JobFailureSummary) {
1015 match &mut self.dispatch {
1016 SinkDispatch::Closures { on_job_failed, .. } => {
1017 if let Some(f) = on_job_failed {
1018 f(summary)
1019 }
1020 }
1021 SinkDispatch::Handler(h) => h.on_job_failed(summary),
1022 }
1023 }
1024
1025 pub(crate) fn dispatch_delivery_error(&mut self, error: &PacketSinkError) {
1026 match &mut self.dispatch {
1027 SinkDispatch::Closures {
1028 on_delivery_error, ..
1029 } => {
1030 if let Some(f) = on_delivery_error {
1031 f(error)
1032 }
1033 }
1034 SinkDispatch::Handler(h) => h.on_delivery_error(error),
1035 }
1036 }
1037
1038 /// Consumes the sink, dropping every user callback box under its OWN
1039 /// panic containment. The derived drop glue runs the boxes as one
1040 /// chain: after a first capture destructor panics, the REMAINING boxes
1041 /// are destroyed by that unwind itself, where a second panicking
1042 /// destructor aborts the process — so one `catch_unwind` around a plain
1043 /// `drop` of the whole aggregate contains only the first bomb. Dropping
1044 /// each box under its own catch keeps every unwind single. Returns true
1045 /// when any destructor panicked (each caught payload is disposed
1046 /// through [`dispose_panic_payload`], never re-dropped raw).
1047 pub(crate) fn dispose_contained(self) -> bool {
1048 let Self {
1049 tier: _,
1050 dispatch,
1051 cancellation,
1052 } = self;
1053 let mut panicked = false;
1054 match dispatch {
1055 SinkDispatch::Closures {
1056 on_stream_info,
1057 on_packet,
1058 on_end,
1059 on_job_failed,
1060 on_delivery_error,
1061 } => {
1062 // Field declaration order — the order the derived drop glue
1063 // would have used.
1064 if let Some(f) = on_stream_info {
1065 panicked |= drop_contained(f);
1066 }
1067 panicked |= drop_contained(on_packet);
1068 if let Some(f) = on_end {
1069 panicked |= drop_contained(f);
1070 }
1071 if let Some(f) = on_job_failed {
1072 panicked |= drop_contained(f);
1073 }
1074 if let Some(f) = on_delivery_error {
1075 panicked |= drop_contained(f);
1076 }
1077 }
1078 SinkDispatch::Handler(handler) => {
1079 panicked |= drop_contained(handler);
1080 }
1081 }
1082 // The cancellation slot is crate data, but the shared job result
1083 // behind it can hold arbitrary error types; the (normally non-final)
1084 // Arc release is contained for the same price as the boxes.
1085 if let Some(slot) = cancellation {
1086 panicked |= drop_contained(slot);
1087 }
1088 panicked
1089 }
1090}
1091
1092/// Drops `value` under its own panic containment. Returns true when the
1093/// destructor panicked; the caught payload is disposed, not re-dropped raw.
1094fn drop_contained<T>(value: T) -> bool {
1095 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(value))) {
1096 Ok(()) => false,
1097 Err(payload) => {
1098 dispose_panic_payload(payload);
1099 true
1100 }
1101 }
1102}
1103
1104/// Disposes a caught panic payload without letting the payload's own
1105/// destructor start a second, uncontained unwind at the discard site.
1106///
1107/// `panic_any` lets panicking user code throw an ARBITRARY payload, and
1108/// nothing forbids one whose `Drop` panics again — with yet another such
1109/// payload. Discarding a `catch_unwind` error via `.is_err()` / `let _` /
1110/// a wildcard therefore runs an uncontained user destructor exactly where
1111/// the containment believed the panic was over. Each drop attempt here runs
1112/// under its own catch, following replacement payloads a bounded number of
1113/// times; a chain still panicking after the last attempt is deliberately
1114/// LEAKED via `mem::forget`. That trade is intentional: a bounded leak on
1115/// an adversarial path is recoverable, while re-throwing would unwind
1116/// frames that may still own user state — and a destructor panic during
1117/// that unwind escalates to a process abort.
1118///
1119/// The containment boundary, here and in every catch this module owns, is
1120/// per BOX: a panic thrown by a callback, or by ONE destructor (a
1121/// capture's, a stashed error source's, or this payload's), is contained.
1122/// A box whose own captured fields' destructors panic DURING that unwind —
1123/// two bombs inside one erased `Box<dyn ..>` — aborts the process by
1124/// Rust's panic-during-unwind rule before any catch regains control: the
1125/// box destroys its captures as one indivisible drop-glue call that no
1126/// code outside the box can decompose. That is identical to any Rust code
1127/// path (a plain struct with two panicking field destructors aborts the
1128/// same way), so it is documented as the boundary, not worked around.
1129pub(crate) fn dispose_panic_payload(payload: Box<dyn std::any::Any + Send>) {
1130 let mut payload = payload;
1131 for _ in 0..4 {
1132 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(payload))) {
1133 Ok(()) => return,
1134 Err(next) => payload = next,
1135 }
1136 }
1137 std::mem::forget(payload);
1138}
1139
1140/// Cancellation-aware bounded send: blocks (in bounded slices) while the
1141/// channel is full, but bails out cooperatively once the job is stopping — so
1142/// `stop()`/`abort()` (or a failure elsewhere) terminates even with a full,
1143/// undrained channel — and reports a dropped receiver as a typed
1144/// disconnection.
1145fn send_with_cancellation(
1146 tx: &crossbeam_channel::Sender<PacketSinkEvent>,
1147 cancellation: &CancellationSlot,
1148 event: PacketSinkEvent,
1149) -> PacketCallbackResult {
1150 // Fast path: `send_timeout` computes a wall-clock deadline up front on
1151 // every call — pure overhead while the channel has capacity (the common
1152 // case). Only a full channel proceeds to the deadline-based slices.
1153 let mut event = match tx.try_send(event) {
1154 Ok(()) => return Ok(()),
1155 Err(crossbeam_channel::TrySendError::Disconnected(_)) => {
1156 return Err(PacketCallbackError::disconnected());
1157 }
1158 Err(crossbeam_channel::TrySendError::Full(back)) => back,
1159 };
1160 loop {
1161 match tx.send_timeout(event, Duration::from_millis(50)) {
1162 Ok(()) => return Ok(()),
1163 Err(crossbeam_channel::SendTimeoutError::Timeout(back)) => {
1164 event = back;
1165 if let Some(observables) = cancellation.get() {
1166 if is_stopping(observables.status.load(Ordering::Acquire)) {
1167 // Classify WHY the job is stopping. A natural
1168 // (all-muxers-done) STATUS_END cannot exist while
1169 // this sink is still delivering — the sink is itself
1170 // one of those muxers — so a stopping status here is
1171 // either explicit stop()/abort() (no error recorded:
1172 // stay silent as cancellation) or a failure-driven
1173 // shutdown. Failures record their error BEFORE
1174 // publishing the terminal status, so the recorded
1175 // result is already visible on this path and the
1176 // terminal can report the truncation as JobFailed.
1177 let failed = observables
1178 .result
1179 .lock()
1180 .unwrap_or_else(std::sync::PoisonError::into_inner)
1181 .as_ref()
1182 .is_some_and(|result| result.is_err());
1183 return Err(if failed {
1184 PacketCallbackError::job_stopped()
1185 } else {
1186 PacketCallbackError::cancelled()
1187 });
1188 }
1189 }
1190 }
1191 Err(crossbeam_channel::SendTimeoutError::Disconnected(_)) => {
1192 return Err(PacketCallbackError::disconnected());
1193 }
1194 }
1195 }
1196}
1197
1198/// Builder for a strict-tier [`PacketSink`]; created by
1199/// [`PacketSink::builder`] with the required packet consumer.
1200pub struct PacketSinkBuilder {
1201 tier: PacketSinkTier,
1202 on_stream_info: Option<StreamInfoFn>,
1203 on_packet: PacketFn,
1204 on_end: Option<EndFn>,
1205 on_job_failed: Option<JobFailedFn>,
1206 on_delivery_error: Option<DeliveryErrorFn>,
1207}
1208
1209impl PacketSinkBuilder {
1210 /// One-time stream configuration callback, invoked before any packet.
1211 /// Return `Ok(())` to accept; an error fails the job before any packet is
1212 /// delivered. Teardown panic containment is per callback box — see
1213 /// "Failure and panic" in the [module docs](self).
1214 pub fn on_stream_info<F>(mut self, f: F) -> Self
1215 where
1216 F: FnMut(&[PacketStreamInfo]) -> PacketCallbackResult + Send + 'static,
1217 {
1218 self.on_stream_info = Some(Box::new(f));
1219 self
1220 }
1221
1222 /// Terminal success callback; see the [module docs](self) for the exact
1223 /// gate. Never invoked after an error or lost packets. Cancellation
1224 /// suppresses it only when it interrupts delivery: a `stop()` that lands
1225 /// after this sink fully drained still delivers `on_end`. A panic here
1226 /// is contained per callback box and cannot change the settled job
1227 /// result — see "Failure and panic" in the [module docs](self) for the
1228 /// exact boundary.
1229 pub fn on_end<F>(mut self, f: F) -> Self
1230 where
1231 F: FnMut() + Send + 'static,
1232 {
1233 self.on_end = Some(Box::new(f));
1234 self
1235 }
1236
1237 /// Optional structured observer for a job that failed OUTSIDE this
1238 /// sink's delivery path.
1239 ///
1240 /// Scope — this fires ONLY on the synthesized-JobFailed path: the job
1241 /// failed elsewhere (a sibling output, an upstream demuxer, decoder,
1242 /// filter or encoder) while this sink's own delivery was clean, whether
1243 /// that failure landed after this sink drained or truncated its
1244 /// delivery. It does NOT fire for this sink's own delivery-path errors
1245 /// (strict-tier violations, failing callbacks) — those already deliver
1246 /// the full typed [`PacketSinkError`] by reference to
1247 /// `on_delivery_error`, and no summary is synthesized for them. Like
1248 /// the terminal callbacks, it never fires for cancellation, aborts, or
1249 /// initial configuration failures.
1250 ///
1251 /// When it fires, it fires exactly once, immediately BEFORE the
1252 /// matching `on_delivery_error(&PacketSinkError::JobFailed { .. })` —
1253 /// same delivery thread, same terminal slot. The summary's
1254 /// [`message`](JobFailureSummary::message) is byte-identical to that
1255 /// `JobFailed` message; the summary adds a coarse [`JobFailureKind`]
1256 /// and, where the recorded error visibly carries them, the raw FFmpeg
1257 /// error code and the output stream index. `wait()`/`stop()` keep
1258 /// returning the original job error exactly as without this callback.
1259 ///
1260 /// A panic here is contained per callback box and cannot skip the
1261 /// `on_delivery_error` dispatch that follows, nor change the settled
1262 /// job result — see "Failure and panic" in the [module docs](self).
1263 /// Leaving this unregistered (the default) changes no behavior
1264 /// anywhere.
1265 pub fn on_job_failed<F>(mut self, f: F) -> Self
1266 where
1267 F: FnMut(&JobFailureSummary) + Send + 'static,
1268 {
1269 self.on_job_failed = Some(Box::new(f));
1270 self
1271 }
1272
1273 /// Terminal failure callback. For delivery-path errors (strict-tier
1274 /// violations, failing callbacks) the same error is also returned by
1275 /// `wait()`/`stop()`; when the JOB failed elsewhere (after this sink
1276 /// drained or truncating its delivery), the callback receives a
1277 /// synthesized [`PacketSinkError::JobFailed`] summarizing that failure
1278 /// while the job keeps its original error (a registered
1279 /// [`on_job_failed`](Self::on_job_failed) observer receives the
1280 /// structured summary immediately before this dispatch). Not invoked
1281 /// for cancellation or initial configuration failures — see "Failure
1282 /// and panic" in the [module docs](self).
1283 ///
1284 /// The borrowed error stays in the worker's custody for the whole call
1285 /// (a panic here cannot run the error source's destructor mid-unwind),
1286 /// and the panic is contained per callback box — see "Failure and
1287 /// panic" in the [module docs](self) for the exact boundary.
1288 pub fn on_delivery_error<F>(mut self, f: F) -> Self
1289 where
1290 F: FnMut(&PacketSinkError) + Send + 'static,
1291 {
1292 self.on_delivery_error = Some(Box::new(f));
1293 self
1294 }
1295
1296 /// Finalizes the sink.
1297 pub fn build(self) -> PacketSink {
1298 PacketSink {
1299 tier: self.tier,
1300 dispatch: SinkDispatch::Closures {
1301 on_stream_info: self.on_stream_info,
1302 on_packet: self.on_packet,
1303 on_end: self.on_end,
1304 on_job_failed: self.on_job_failed,
1305 on_delivery_error: self.on_delivery_error,
1306 },
1307 cancellation: None,
1308 }
1309 }
1310}
1311
1312/// Owned copy of one delivered packet, produced by [`PacketSink::channel`].
1313#[non_exhaustive]
1314#[derive(Debug, Clone)]
1315pub struct EncodedPacket {
1316 pub(crate) stream_index: usize,
1317 pub(crate) pts: i64,
1318 pub(crate) dts: i64,
1319 pub(crate) duration: i64,
1320 pub(crate) time_base: AVRational,
1321 pub(crate) is_key: bool,
1322 pub(crate) applied_offset: i64,
1323 pub(crate) data: Vec<u8>,
1324}
1325
1326impl EncodedPacket {
1327 fn from_view(view: &PacketView<'_>) -> Self {
1328 Self {
1329 stream_index: view.stream_index,
1330 pts: view.pts,
1331 dts: view.dts,
1332 duration: view.duration,
1333 time_base: view.time_base,
1334 is_key: view.is_key,
1335 applied_offset: view.applied_offset,
1336 data: view.data.to_vec(),
1337 }
1338 }
1339
1340 /// Output stream index.
1341 pub fn stream_index(&self) -> usize {
1342 self.stream_index
1343 }
1344
1345 /// Presentation timestamp; see [`PacketView::pts`].
1346 pub fn pts(&self) -> i64 {
1347 self.pts
1348 }
1349
1350 /// Decode timestamp; see [`PacketView::dts`].
1351 pub fn dts(&self) -> i64 {
1352 self.dts
1353 }
1354
1355 /// Packet duration; see [`PacketView::duration`].
1356 pub fn duration(&self) -> i64 {
1357 self.duration
1358 }
1359
1360 /// Stream time base.
1361 pub fn time_base(&self) -> AVRational {
1362 self.time_base
1363 }
1364
1365 /// [`pts`](Self::pts) in microseconds.
1366 pub fn pts_us(&self) -> i64 {
1367 ticks_to_us(self.pts, self.time_base)
1368 }
1369
1370 /// [`dts`](Self::dts) in microseconds.
1371 pub fn dts_us(&self) -> i64 {
1372 ticks_to_us(self.dts, self.time_base)
1373 }
1374
1375 /// [`duration`](Self::duration) in microseconds.
1376 pub fn duration_us(&self) -> i64 {
1377 ticks_to_us(self.duration, self.time_base)
1378 }
1379
1380 /// [`applied_offset`](Self::applied_offset) in microseconds.
1381 pub fn applied_offset_us(&self) -> i64 {
1382 ticks_to_us(self.applied_offset, self.time_base)
1383 }
1384
1385 /// Fresh-decoder-safe random access point; see [`PacketView::is_key`].
1386 pub fn is_key(&self) -> bool {
1387 self.is_key
1388 }
1389
1390 /// Applied origin shift; see [`PacketView::applied_offset`].
1391 pub fn applied_offset(&self) -> i64 {
1392 self.applied_offset
1393 }
1394
1395 /// The owned packet payload.
1396 pub fn data(&self) -> &[u8] {
1397 &self.data
1398 }
1399
1400 /// Consumes the packet, returning the payload.
1401 pub fn into_data(self) -> Vec<u8> {
1402 self.data
1403 }
1404}
1405
1406/// One event delivered over a [`PacketSink::channel`] adapter, mirroring the
1407/// callback order: `StreamInfo`, then `Packet`s, then at most one terminal
1408/// `End`/`Error` (terminal events are best-effort under a stalled consumer;
1409/// sender disconnection is authoritative).
1410#[non_exhaustive]
1411#[derive(Debug, Clone)]
1412pub enum PacketSinkEvent {
1413 /// The one-time stream configuration, one entry per output stream.
1414 StreamInfo(Vec<PacketStreamInfo>),
1415 /// One delivered packet, copied into an owned payload.
1416 Packet(EncodedPacket),
1417 /// Terminal success (best-effort; see the enum docs).
1418 End,
1419 /// A structured summary of a job failure recorded OUTSIDE this sink's
1420 /// delivery path, queued immediately before the matching
1421 /// [`Error`](Self::Error) event carrying
1422 /// [`PacketSinkError::JobFailed`]. Emitted only on that synthesis path —
1423 /// a delivery-path error produces just the `Error` event. Best-effort
1424 /// like [`End`](Self::End), and one notch behind the `Error` event it
1425 /// precedes: the summary is dropped rather than ever taking the last
1426 /// free slot the terminal `Error` would have used.
1427 JobFailure(JobFailureSummary),
1428 /// A delivery-path error, or [`PacketSinkError::JobFailed`] when the job
1429 /// failed elsewhere (`wait()` keeps the original error). Best-effort like
1430 /// [`End`](Self::End).
1431 Error(PacketSinkError),
1432}
1433
1434/// Why [`PacketSinkReceiver::recv`] returned no event.
1435#[non_exhaustive]
1436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1437pub enum PacketRecvError {
1438 /// The sending side is gone (job finished or failed; all events
1439 /// consumed).
1440 Disconnected,
1441}
1442
1443impl std::fmt::Display for PacketRecvError {
1444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1445 f.write_str("packet-sink channel disconnected")
1446 }
1447}
1448
1449impl std::error::Error for PacketRecvError {}
1450
1451/// Why [`PacketSinkReceiver::try_recv`] returned no event.
1452#[non_exhaustive]
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454pub enum PacketTryRecvError {
1455 /// No event is currently queued.
1456 Empty,
1457 /// The sending side is gone.
1458 Disconnected,
1459}
1460
1461impl std::fmt::Display for PacketTryRecvError {
1462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1463 match self {
1464 PacketTryRecvError::Empty => f.write_str("no packet-sink event queued"),
1465 PacketTryRecvError::Disconnected => f.write_str("packet-sink channel disconnected"),
1466 }
1467 }
1468}
1469
1470impl std::error::Error for PacketTryRecvError {}
1471
1472/// Why [`PacketSinkReceiver::recv_timeout`] returned no event.
1473#[non_exhaustive]
1474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1475pub enum PacketRecvTimeoutError {
1476 /// No event arrived within the timeout.
1477 Timeout,
1478 /// The sending side is gone.
1479 Disconnected,
1480}
1481
1482impl std::fmt::Display for PacketRecvTimeoutError {
1483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1484 match self {
1485 PacketRecvTimeoutError::Timeout => {
1486 f.write_str("timed out waiting for a packet-sink event")
1487 }
1488 PacketRecvTimeoutError::Disconnected => f.write_str("packet-sink channel disconnected"),
1489 }
1490 }
1491}
1492
1493impl std::error::Error for PacketRecvTimeoutError {}
1494
1495/// Error from [`PacketSinkReceiver::into_events`]: the scheduler passed in
1496/// is not the one running this receiver's sink.
1497///
1498/// Each [`PacketSink::channel`] call shares an identity token between the
1499/// sink and its receiver, and `into_events` requires the scheduler whose job
1500/// contains that sink. Accepting an arbitrary scheduler would silently
1501/// cross-wire two runs: iterate receiver A's events while joining — and, on
1502/// early drop, aborting — job B. Both handles are returned unchanged so the
1503/// caller can pair them correctly (the scheduler's job keeps running).
1504pub struct PacketEventsPairingError {
1505 /// The receiver, returned unchanged.
1506 pub receiver: PacketSinkReceiver,
1507 /// The scheduler, returned unchanged; its job is unaffected.
1508 pub scheduler: FfmpegScheduler<Running>,
1509}
1510
1511impl std::fmt::Debug for PacketEventsPairingError {
1512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1513 f.debug_struct("PacketEventsPairingError")
1514 .finish_non_exhaustive()
1515 }
1516}
1517
1518impl std::fmt::Display for PacketEventsPairingError {
1519 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1520 f.write_str("packet-sink receiver paired with a scheduler that is not running its sink")
1521 }
1522}
1523
1524impl std::error::Error for PacketEventsPairingError {}
1525
1526/// Receiving side of a [`PacketSink::channel`] adapter.
1527///
1528/// Drain it concurrently with the running job (its own thread, or
1529/// [`into_events`](Self::into_events)); dropping the receiver cancels the
1530/// job — the next delivery fails typed instead of blocking forever.
1531pub struct PacketSinkReceiver {
1532 inner: crossbeam_channel::Receiver<PacketSinkEvent>,
1533 /// Identity of the `PacketSink::channel` call that produced this
1534 /// receiver: the same `CancellationSlot` Arc the paired sink carries.
1535 /// [`into_events`](Self::into_events) matches it by pointer identity
1536 /// against the scheduler's job.
1537 token: CancellationSlot,
1538}
1539
1540impl PacketSinkReceiver {
1541 /// Blocks until the next event; [`PacketRecvError::Disconnected`] once
1542 /// the sending side is gone and all events were consumed.
1543 pub fn recv(&self) -> Result<PacketSinkEvent, PacketRecvError> {
1544 self.inner.recv().map_err(|_| PacketRecvError::Disconnected)
1545 }
1546
1547 /// Non-blocking receive, distinguishing an empty channel from a
1548 /// disconnected one.
1549 pub fn try_recv(&self) -> Result<PacketSinkEvent, PacketTryRecvError> {
1550 self.inner.try_recv().map_err(|e| match e {
1551 crossbeam_channel::TryRecvError::Empty => PacketTryRecvError::Empty,
1552 crossbeam_channel::TryRecvError::Disconnected => PacketTryRecvError::Disconnected,
1553 })
1554 }
1555
1556 /// Receive with a timeout, distinguishing a timeout from disconnection.
1557 pub fn recv_timeout(
1558 &self,
1559 timeout: Duration,
1560 ) -> Result<PacketSinkEvent, PacketRecvTimeoutError> {
1561 self.inner.recv_timeout(timeout).map_err(|e| match e {
1562 crossbeam_channel::RecvTimeoutError::Timeout => PacketRecvTimeoutError::Timeout,
1563 crossbeam_channel::RecvTimeoutError::Disconnected => {
1564 PacketRecvTimeoutError::Disconnected
1565 }
1566 })
1567 }
1568
1569 /// Blocking iterator over events until the sender disconnects.
1570 pub fn iter(&self) -> impl Iterator<Item = PacketSinkEvent> + '_ {
1571 self.inner.iter()
1572 }
1573
1574 /// Consumes the receiver and the running scheduler into a single
1575 /// owned-run iterator (the frame-export `FrameIter` shape): events stream
1576 /// out as they arrive, the scheduler is joined exactly once when the
1577 /// channel drains, and a job error surfaces as one terminal `Err`.
1578 /// Dropping the iterator mid-run releases the receiver FIRST (unblocking
1579 /// a worker parked in the channel send), then aborts the job.
1580 ///
1581 /// Unlike the raw channel — whose terminal events are best-effort — the
1582 /// iterator's ending is deterministic: a stream that ends without `Err`
1583 /// always ends with exactly one [`PacketSinkEvent::End`]. The channel
1584 /// send behind `on_end` cannot block teardown, so a consumer that is
1585 /// merely a few events behind at that instant loses the queued `End`;
1586 /// the iterator re-synthesizes it after the clean join, where "clean"
1587 /// is `wait()` returning `Ok` — the same authority the completion
1588 /// contract pins to a delivered `on_end`.
1589 ///
1590 /// # Failure path
1591 ///
1592 /// On a failed job the iterator may first yield the best-effort channel
1593 /// events queued at the terminal —
1594 /// `Ok(`[`PacketSinkEvent::JobFailure`]`(summary))`, then
1595 /// `Ok(`[`PacketSinkEvent::Error`]`(PacketSinkError::JobFailed { .. }))`.
1596 /// Both are best-effort: the `Error` event is dropped when the channel
1597 /// is full at that instant, and the `JobFailure` summary is stricter
1598 /// still — it requires two free slots, never taking the last slot the
1599 /// terminal `Error` would use. Then follows the single deterministic
1600 /// terminal `Err` carrying the original job error from the join. That
1601 /// `Err` is authoritative (the same authority as `wait()`); the
1602 /// `JobFailed`/summary message is byte-identical to that error's
1603 /// `Display` output (or to the fixed substitute message when formatting
1604 /// the recorded error panicked).
1605 ///
1606 /// # Errors
1607 ///
1608 /// [`PacketEventsPairingError`] when `scheduler` is not the one running
1609 /// this receiver's sink. The pairing is checked by identity — the token
1610 /// shared by the sink/receiver pair from [`PacketSink::channel`] must
1611 /// belong to the scheduler's job — because a cross-wired iterator would
1612 /// silently stream one run's events while reporting (and, on drop,
1613 /// aborting) another run's outcome. The error returns both handles
1614 /// unchanged so they can be re-paired.
1615 // The Err variant carries the scheduler back to the caller, so it is as
1616 // large as the Ok variant (which owns the same scheduler inside the
1617 // iterator); boxing it would not shrink the Result.
1618 #[allow(clippy::result_large_err)]
1619 pub fn into_events(
1620 self,
1621 scheduler: FfmpegScheduler<Running>,
1622 ) -> Result<PacketEventIter, PacketEventsPairingError> {
1623 if !scheduler.runs_packet_sink(&self.token) {
1624 return Err(PacketEventsPairingError {
1625 receiver: self,
1626 scheduler,
1627 });
1628 }
1629 Ok(PacketEventIter {
1630 inner: OwnedRunIter::new(self.inner, scheduler, std::convert::identity),
1631 saw_end: false,
1632 saw_error: false,
1633 })
1634 }
1635}
1636
1637/// An owned-run iterator over [`PacketSinkEvent`]s; see
1638/// [`PacketSinkReceiver::into_events`].
1639pub struct PacketEventIter {
1640 inner: OwnedRunIter<PacketSinkEvent>,
1641 /// An `End` already streamed through the channel — nothing to add.
1642 saw_end: bool,
1643 /// A terminal `Err` was yielded — an `End` after it would claim a clean
1644 /// finish the join just denied.
1645 saw_error: bool,
1646}
1647
1648impl std::fmt::Debug for PacketEventIter {
1649 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1650 f.debug_struct("PacketEventIter").finish_non_exhaustive()
1651 }
1652}
1653
1654impl Iterator for PacketEventIter {
1655 type Item = Result<PacketSinkEvent, crate::error::Error>;
1656
1657 fn next(&mut self) -> Option<Self::Item> {
1658 match self.inner.next() {
1659 Some(Ok(event)) => {
1660 if matches!(event, PacketSinkEvent::End) {
1661 self.saw_end = true;
1662 }
1663 Some(Ok(event))
1664 }
1665 Some(Err(e)) => {
1666 self.saw_error = true;
1667 Some(Err(e))
1668 }
1669 // The channel disconnected and the join was clean. `on_end`
1670 // pushes `End` with a non-blocking send (teardown must not wait
1671 // on a lagging consumer), so a consumer a few events behind at
1672 // that instant loses it; restore the invariant that a stream
1673 // without `Err` ends with `End`, exactly once.
1674 None => {
1675 if !self.saw_end && !self.saw_error {
1676 self.saw_end = true;
1677 Some(Ok(PacketSinkEvent::End))
1678 } else {
1679 None
1680 }
1681 }
1682 }
1683 }
1684}
1685
1686impl std::iter::FusedIterator for PacketEventIter {}
1687
1688/// Explicit muxing policy a packet-sink output pins for encoder setup.
1689///
1690/// A packet sink still allocates a real (never-written) output context so the
1691/// existing stream/parameter plumbing works unchanged, but nothing may be
1692/// implicitly decided by which container that happens to be: the flags the
1693/// encoders and the vsync selection observe are synthesized from this policy
1694/// and overwrite the container's snapshot.
1695pub(crate) struct PacketSinkPolicy {
1696 /// Set `AV_CODEC_FLAG_GLOBAL_HEADER` on the encoders, so codec
1697 /// configuration (SPS/PPS, AudioSpecificConfig) materializes as
1698 /// out-of-band extradata at encoder open — the strict tier requires it
1699 /// before the first callback.
1700 pub(crate) global_header: bool,
1701 /// Advertise variable-fps semantics to the vsync selection (`false` in
1702 /// the strict tier: CFR-style timestamps, like mp4).
1703 pub(crate) variable_fps: bool,
1704 /// Advertise a timestamp-free sink to the vsync selection (`false`:
1705 /// timestamps are the product).
1706 pub(crate) no_timestamps: bool,
1707}
1708
1709impl PacketSinkPolicy {
1710 pub(crate) fn for_tier(tier: PacketSinkTier) -> Self {
1711 match tier {
1712 PacketSinkTier::Strict => Self {
1713 global_header: true,
1714 variable_fps: false,
1715 no_timestamps: false,
1716 },
1717 }
1718 }
1719
1720 /// The `AVOutputFormat.flags` projection of this policy, stored as the
1721 /// muxer's `oformat_flags` snapshot (what `enc_init` and the vsync
1722 /// selection read).
1723 pub(crate) fn oformat_flags(&self) -> i32 {
1724 let mut flags = 0;
1725 if self.global_header {
1726 flags |= ffmpeg_sys_next::AVFMT_GLOBALHEADER;
1727 }
1728 if self.variable_fps {
1729 flags |= ffmpeg_sys_next::AVFMT_VARIABLE_FPS;
1730 }
1731 if self.no_timestamps {
1732 flags |= ffmpeg_sys_next::AVFMT_NOTIMESTAMPS;
1733 }
1734 flags
1735 }
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740 use super::*;
1741
1742 fn test_view(data: &[u8]) -> PacketView<'_> {
1743 PacketView {
1744 stream_index: 0,
1745 pts: 10,
1746 dts: 5,
1747 duration: 1,
1748 time_base: AVRational { num: 1, den: 25 },
1749 is_key: false,
1750 applied_offset: 3,
1751 data,
1752 }
1753 }
1754
1755 #[test]
1756 fn builder_requires_a_packet_consumer_and_discard_is_explicit() {
1757 let mut sink = PacketSink::builder(|_| Ok(())).build();
1758 assert_eq!(sink.tier, PacketSinkTier::Strict);
1759 assert!(sink.dispatch_stream_info(&[]).is_ok());
1760 let payload = [0u8, 0, 0, 1, 0x65];
1761 assert!(sink.dispatch_packet(&test_view(&payload)).is_ok());
1762 sink.dispatch_end();
1763 sink.dispatch_delivery_error(&PacketSinkError::NoStreams);
1764
1765 let mut discard = PacketSink::discard();
1766 assert!(discard.dispatch_packet(&test_view(&payload)).is_ok());
1767 }
1768
1769 /// The public tier accessor is the only way consumers can observe a
1770 /// sink's tier; every v1 construction path must report `Strict`, and the
1771 /// enum default must agree so builders can rely on it.
1772 #[test]
1773 fn every_construction_path_reports_the_strict_tier() {
1774 assert_eq!(PacketSinkTier::default(), PacketSinkTier::Strict);
1775 assert_eq!(
1776 PacketSink::builder(|_| Ok(())).build().tier(),
1777 PacketSinkTier::Strict
1778 );
1779 assert_eq!(PacketSink::discard().tier(), PacketSinkTier::Strict);
1780
1781 struct Accepting;
1782 impl PacketSinkHandler for Accepting {
1783 fn on_packet(&mut self, _packet: &PacketView<'_>) -> PacketCallbackResult {
1784 Ok(())
1785 }
1786 }
1787 assert_eq!(
1788 PacketSink::from_handler(Accepting).tier(),
1789 PacketSinkTier::Strict
1790 );
1791
1792 let (sink, _receiver) = PacketSink::channel(NonZeroUsize::new(1).unwrap());
1793 assert_eq!(sink.tier(), PacketSinkTier::Strict);
1794 }
1795
1796 #[test]
1797 fn handler_receives_serial_callbacks_with_shared_state() {
1798 struct Counting {
1799 packets: usize,
1800 }
1801 impl PacketSinkHandler for Counting {
1802 fn on_packet(&mut self, _packet: &PacketView<'_>) -> PacketCallbackResult {
1803 self.packets += 1;
1804 if self.packets > 1 {
1805 Err(PacketCallbackError::new("enough"))
1806 } else {
1807 Ok(())
1808 }
1809 }
1810 }
1811 let mut sink = PacketSink::from_handler(Counting { packets: 0 });
1812 let payload = [0u8, 0, 0, 1, 0x65];
1813 assert!(sink.dispatch_packet(&test_view(&payload)).is_ok());
1814 let err = sink
1815 .dispatch_packet(&test_view(&payload))
1816 .expect_err("handler state must persist across calls");
1817 assert_eq!(err.kind, CallbackFailureKind::Failure);
1818 assert_eq!(err.to_string(), "enough");
1819 }
1820
1821 /// A `panic_any` payload whose own `Drop` panics (with another such
1822 /// payload) must be consumed without the disposal itself throwing —
1823 /// both for chains within the attempt bound and for chains beyond it
1824 /// (the remainder is leaked by design, never re-thrown).
1825 #[test]
1826 fn panic_payload_chains_are_disposed_without_escaping() {
1827 struct ChainBomb(u32);
1828 impl Drop for ChainBomb {
1829 fn drop(&mut self) {
1830 if self.0 > 0 {
1831 std::panic::panic_any(ChainBomb(self.0 - 1));
1832 }
1833 }
1834 }
1835 // Depth 3: attempts 1-3 each panic with the next link, attempt 4
1836 // drops the final link cleanly.
1837 dispose_panic_payload(Box::new(ChainBomb(3)));
1838 // Deeper than the attempt bound: the helper must still return.
1839 dispose_panic_payload(Box::new(ChainBomb(64)));
1840 }
1841
1842 /// Every callback box must be destroyed even when SEVERAL capture
1843 /// destructors panic. One catch around a plain drop of the aggregate
1844 /// contains only the first bomb — the remaining boxes are then
1845 /// destroyed by the unwind itself, where the second bomb aborts the
1846 /// process. Reaching the assertions at all is the point.
1847 #[test]
1848 fn dispose_contained_destroys_every_box_across_multiple_drop_panics() {
1849 use std::sync::atomic::AtomicBool;
1850
1851 struct DropBomb(Arc<AtomicBool>);
1852 impl Drop for DropBomb {
1853 fn drop(&mut self) {
1854 self.0.store(true, Ordering::Release);
1855 panic!("injected capture-destructor panic");
1856 }
1857 }
1858
1859 let flags: Vec<Arc<AtomicBool>> =
1860 (0..4).map(|_| Arc::new(AtomicBool::new(false))).collect();
1861 let (b0, b1, b2, b3) = (
1862 DropBomb(flags[0].clone()),
1863 DropBomb(flags[1].clone()),
1864 DropBomb(flags[2].clone()),
1865 DropBomb(flags[3].clone()),
1866 );
1867 let sink = PacketSink::builder(move |_pkt| {
1868 let _hold = &b0;
1869 Ok(())
1870 })
1871 .on_end(move || {
1872 let _hold = &b1;
1873 })
1874 .on_job_failed(move |_summary| {
1875 let _hold = &b2;
1876 })
1877 .on_delivery_error(move |_e| {
1878 let _hold = &b3;
1879 })
1880 .build();
1881 assert!(
1882 sink.dispose_contained(),
1883 "four panicking capture destructors must be reported"
1884 );
1885 for (i, flag) in flags.iter().enumerate() {
1886 assert!(
1887 flag.load(Ordering::Acquire),
1888 "callback box {i} was never destroyed"
1889 );
1890 }
1891
1892 struct BombHandler(Arc<AtomicBool>);
1893 impl Drop for BombHandler {
1894 fn drop(&mut self) {
1895 self.0.store(true, Ordering::Release);
1896 panic!("injected handler-destructor panic");
1897 }
1898 }
1899 impl PacketSinkHandler for BombHandler {
1900 fn on_packet(&mut self, _packet: &PacketView<'_>) -> PacketCallbackResult {
1901 Ok(())
1902 }
1903 }
1904 let destroyed = Arc::new(AtomicBool::new(false));
1905 assert!(PacketSink::from_handler(BombHandler(destroyed.clone())).dispose_contained());
1906 assert!(destroyed.load(Ordering::Acquire));
1907
1908 // Benign sinks report no panic.
1909 assert!(!PacketSink::builder(|_pkt| Ok(()))
1910 .build()
1911 .dispose_contained());
1912 }
1913
1914 #[test]
1915 fn callback_error_preserves_its_source() {
1916 let io = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "peer gone");
1917 let err = PacketCallbackError::with_source("send failed", io);
1918 assert_eq!(err.to_string(), "send failed");
1919 let source = std::error::Error::source(&err).expect("source preserved");
1920 assert!(source.to_string().contains("peer gone"));
1921 }
1922
1923 #[test]
1924 fn channel_adapter_forwards_events_in_order() {
1925 let (mut sink, rx) = PacketSink::channel(NonZeroUsize::new(8).unwrap());
1926 assert!(sink.dispatch_stream_info(&[]).is_ok());
1927 let payload = [0u8, 0, 0, 1, 0x65];
1928 assert!(sink.dispatch_packet(&test_view(&payload)).is_ok());
1929 sink.dispatch_end();
1930 match rx.recv().unwrap() {
1931 PacketSinkEvent::StreamInfo(v) => assert!(v.is_empty()),
1932 other => panic!("expected StreamInfo, got {other:?}"),
1933 }
1934 match rx.recv().unwrap() {
1935 PacketSinkEvent::Packet(p) => {
1936 assert_eq!(p.pts(), 10);
1937 assert_eq!(p.dts(), 5);
1938 assert_eq!(p.applied_offset(), 3);
1939 assert_eq!(p.data(), &payload);
1940 assert!(!p.is_key());
1941 // Tick conveniences: 10 ticks at 1/25 s = 400_000 us.
1942 assert_eq!(p.pts_us(), 400_000);
1943 assert_eq!(p.duration_us(), 40_000);
1944 }
1945 other => panic!("expected Packet, got {other:?}"),
1946 }
1947 assert!(matches!(rx.recv().unwrap(), PacketSinkEvent::End));
1948 drop(sink);
1949 assert!(matches!(rx.recv(), Err(PacketRecvError::Disconnected)));
1950 }
1951
1952 #[test]
1953 fn dropped_receiver_turns_sends_into_typed_disconnection() {
1954 let (mut sink, rx) = PacketSink::channel(NonZeroUsize::new(1).unwrap());
1955 drop(rx);
1956 let payload = [0u8, 0, 0, 1, 0x65];
1957 let err = sink
1958 .dispatch_packet(&test_view(&payload))
1959 .expect_err("send into a dropped receiver must fail");
1960 assert_eq!(err.kind, CallbackFailureKind::Disconnected);
1961 }
1962
1963 /// A blocked bounded send with a live, undrained receiver must observe
1964 /// the job stopping and bail out promptly — classified as clean
1965 /// cancellation when NO job error is recorded.
1966 #[test]
1967 fn blocked_channel_send_observes_cancellation() {
1968 let (mut sink, rx) = PacketSink::channel(NonZeroUsize::new(1).unwrap());
1969 // Simulate the worker wiring: publish the job observables.
1970 let status = Arc::new(AtomicUsize::new(
1971 crate::core::scheduler::ffmpeg_scheduler::STATUS_RUN,
1972 ));
1973 let result = Arc::new(std::sync::Mutex::new(None));
1974 sink.cancellation
1975 .as_ref()
1976 .expect("channel sinks carry a cancellation slot")
1977 .set(JobStopObservables {
1978 status: status.clone(),
1979 result,
1980 })
1981 .ok();
1982 // Fill the capacity-1 channel; the receiver never drains.
1983 let payload = [0u8, 0, 0, 1, 0x65];
1984 assert!(sink.dispatch_packet(&test_view(&payload)).is_ok());
1985 // Flip to stopping from another thread; the blocked send must
1986 // observe it and bail out with the cancellation kind.
1987 let flip = status.clone();
1988 let flipper = std::thread::spawn(move || {
1989 std::thread::sleep(Duration::from_millis(120));
1990 flip.store(
1991 crate::core::scheduler::ffmpeg_scheduler::STATUS_END,
1992 Ordering::Release,
1993 );
1994 });
1995 let start = std::time::Instant::now();
1996 let err = sink
1997 .dispatch_packet(&test_view(&payload))
1998 .expect_err("blocked send must cancel");
1999 assert_eq!(err.kind, CallbackFailureKind::Cancelled);
2000 assert!(
2001 start.elapsed() < Duration::from_secs(5),
2002 "cancellation must be prompt"
2003 );
2004 flipper.join().unwrap();
2005 drop(rx);
2006 }
2007
2008 /// A stopping status WITH a recorded job error is a failure-driven
2009 /// shutdown, not cancellation: the blocked send must classify it as
2010 /// `JobStopped` so the terminal reports `JobFailed` instead of staying
2011 /// silent.
2012 #[test]
2013 fn blocked_channel_send_classifies_failure_driven_stop() {
2014 let (mut sink, rx) = PacketSink::channel(NonZeroUsize::new(1).unwrap());
2015 let status = Arc::new(AtomicUsize::new(
2016 crate::core::scheduler::ffmpeg_scheduler::STATUS_RUN,
2017 ));
2018 let result: Arc<std::sync::Mutex<Option<crate::error::Result<()>>>> =
2019 Arc::new(std::sync::Mutex::new(None));
2020 sink.cancellation
2021 .as_ref()
2022 .expect("channel sinks carry a cancellation slot")
2023 .set(JobStopObservables {
2024 status: status.clone(),
2025 result: result.clone(),
2026 })
2027 .ok();
2028 let payload = [0u8, 0, 0, 1, 0x65];
2029 assert!(sink.dispatch_packet(&test_view(&payload)).is_ok());
2030 // Record the error BEFORE publishing the stopping status — the
2031 // order every failure path guarantees.
2032 let flipper = std::thread::spawn(move || {
2033 std::thread::sleep(Duration::from_millis(120));
2034 *result.lock().unwrap() = Some(Err(crate::error::Error::WorkerPanicked(
2035 "muxer1:mpegts".to_string(),
2036 )));
2037 status.store(
2038 crate::core::scheduler::ffmpeg_scheduler::STATUS_END,
2039 Ordering::Release,
2040 );
2041 });
2042 let err = sink
2043 .dispatch_packet(&test_view(&payload))
2044 .expect_err("blocked send must abandon on a failed job");
2045 assert_eq!(err.kind, CallbackFailureKind::JobStopped);
2046 flipper.join().unwrap();
2047 drop(rx);
2048 }
2049
2050 #[test]
2051 fn recv_variants_distinguish_empty_timeout_disconnected() {
2052 let (sink, rx) = PacketSink::channel(NonZeroUsize::new(1).unwrap());
2053 assert_eq!(rx.try_recv().unwrap_err(), PacketTryRecvError::Empty);
2054 assert_eq!(
2055 rx.recv_timeout(Duration::from_millis(10)).unwrap_err(),
2056 PacketRecvTimeoutError::Timeout
2057 );
2058 drop(sink);
2059 assert_eq!(rx.try_recv().unwrap_err(), PacketTryRecvError::Disconnected);
2060 assert_eq!(
2061 rx.recv_timeout(Duration::from_millis(10)).unwrap_err(),
2062 PacketRecvTimeoutError::Disconnected
2063 );
2064 }
2065}