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