Skip to main content

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