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