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