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}