Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions .github/workflows/ci-ffmpeg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-ffmpeg-build-${{ hashFiles('**/Cargo.lock') }}
# See the `test` job below for the `-v2-` prefix rationale.
key: ${{ runner.os }}-ffmpeg-build-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-ffmpeg-build-
${{ runner.os }}-ffmpeg-build-v2-
- name: Install FFmpeg
run: brew install ffmpeg pkg-config
- name: Install Rust
Expand All @@ -98,15 +99,32 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-ffmpeg-test-${{ hashFiles('**/Cargo.lock') }}
# `-v2-` prefix bump is a one-shot cache invalidation —
# earlier runs cached `ffmpeg-sys-next` artifacts that
# had baked in a stale brew FFmpeg path
# (`/opt/homebrew/Cellar/ffmpeg/<old version>/lib`).
# The runner now has a different patch version, so the
# linker fails with `library 'avutil' not found`. Once
# the new cache populates, the explicit clean step
# below keeps it fresh on every run.
key: ${{ runner.os }}-ffmpeg-test-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-ffmpeg-test-
${{ runner.os }}-ffmpeg-test-v2-
- name: Install FFmpeg
run: brew install ffmpeg pkg-config
- name: Install Rust
run: rustup update stable && rustup default stable
- name: Install cargo-hack
run: cargo install cargo-hack
- name: Refresh ffmpeg-sys-next build artifacts
# `ffmpeg-sys-next`'s `build.rs` bakes the brew-installed
# `/opt/homebrew/Cellar/ffmpeg/<version>/lib` path into its
# linker hints. When brew bumps FFmpeg between runs the
# cached output points at a path that no longer exists and
# the linker fails. Force a rebuild of the sys crate (and
# its downstream binding) so the build script re-reads
# `pkg-config` against the currently-installed FFmpeg.
run: cargo clean -p ffmpeg-sys-next -p ffmpeg-next -p mediadecode-ffmpeg
- name: Run test
run: cargo hack test -p mediadecode-ffmpeg --feature-powerset --exclude-no-default-features

Expand Down Expand Up @@ -136,9 +154,13 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-ffmpeg-coverage-${{ hashFiles('**/Cargo.lock') }}
# See the `test` job above for the `-v2-` prefix + clean
# step rationale. Same brew-ffmpeg-path drift bites here.
key: ${{ runner.os }}-ffmpeg-coverage-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-ffmpeg-coverage-
${{ runner.os }}-ffmpeg-coverage-v2-
- name: Refresh ffmpeg-sys-next build artifacts
run: cargo clean -p ffmpeg-sys-next -p ffmpeg-next -p mediadecode-ffmpeg
- name: Run tarpaulin
env:
RUSTFLAGS: "--cfg tarpaulin"
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ homepage = "https://github.com/findit-ai/mediadecode"

[workspace.dependencies]
# Workspace-internal crates — referenced by sibling members via workspace inheritance.
mediadecode = { version = "0.1.0", path = "mediadecode", default-features = false }
mediadecode = { version = "0.2", path = "mediadecode", default-features = false }
videoframe = { version = "0.2", default-features = false, features = ["frame"] }

# External deps used by more than one member, pinned at the workspace level.
mediatime = { version = "0.1", default-features = false }
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ Generic, `no_std`-friendly type-and-trait spine for media decoders.

## Crates

| Crate | Role |
| ---------------------------------------------------- | ------------------------------------------------------------- |
| [`mediadecode`](mediadecode/README.md) | Backend-agnostic core — `no_std`, no FFmpeg. |
| [`mediadecode-ffmpeg`](mediadecode-ffmpeg/README.md) | FFmpeg adapter with HW-acceleration auto-probe. |
| Crate | Role |
| ---------------------------------------------------------- | ------------------------------------------------------------- |
| [`mediadecode`](mediadecode/README.md) | Backend-agnostic core — `no_std`, no FFmpeg. |
| [`mediadecode-ffmpeg`](mediadecode-ffmpeg/README.md) | FFmpeg adapter with HW-acceleration auto-probe. |
| [`mediadecode-webcodecs`](mediadecode-webcodecs/README.md) | WebCodecs adapter for `wasm32` targets (scaffolded). |

The pixel-format and color-metadata vocabulary (`PixelFormat`,
`ColorMatrix`, `BayerPattern`, frame primitives) is sourced from
[`videoframe`](https://crates.io/crates/videoframe) so colconv,
mediadecode, and scenesdetect share one canonical definition.

See each crate's README for its API surface, usage, and build
requirements.
Expand Down
67 changes: 65 additions & 2 deletions mediadecode-ffmpeg/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,71 @@ The backend-agnostic core it adapts has its own log at

## [Unreleased]

## [0.2.0] - 2026-05-15

Tracks `mediadecode` 0.2.0. The pixel-vocabulary types
(`PixelFormat`, color enums, frame primitives) now live in the
`videoframe` crate and are re-exported through `mediadecode`; this
release adapts the FFmpeg boundary to the new `PixelFormat::Unknown(u32)`
shape and updates the type aliases the crate re-exports.

### Changed (BREAKING)

- **`PixelFormat::Unknown` shape**: re-exported `PixelFormat` is now
`Unknown(u32)` (tuple variant) instead of the prior unit variant
— see [`mediadecode` 0.2.0](../mediadecode/CHANGELOG.md#020---2026-05-15).
- **FFmpeg boundary fallback** now preserves the raw `AVPixelFormat`
identifier through `PixelFormat::Unknown(raw as u32)` instead of
collapsing to a bare `Unknown`. Round-trips losslessly via
`PixelFormat::{from_u32, to_u32}`.
- **Type aliases reshape**: `VideoFrame`, `AudioFrame`,
`SubtitleFrame`, `VideoPacket`, `AudioPacket`, `SubtitlePacket`
inherit the upstream `PixelFormat` shape change. Downstream
callers matching on `Unknown` in destination frames need to
switch to `Unknown(_)`.
- **`Error` enum variants** are now newtype-tuple form wrapping
payload structs (matches the convention in
[`videoframe`](https://crates.io/crates/videoframe)). Affected
variants: `HwDeviceInitFailed`, `AllBackendsFailed`,
`FallbackFailed`. Pure tuple variants (`Ffmpeg`, `NoCodec`,
`BackendUnsupportedByCodec`) unchanged. Callers destructuring
`Err(Error::AllBackendsFailed { attempts, .. })` must switch to
`Err(Error::AllBackendsFailed(p))` and call `p.attempts()` /
`p.unconsumed_packets()`. Owning-move paths for the rescued
packets are preserved via `p.into_unconsumed_packets()` /
`p.into_parts()`, so non-seekable callers can still relinquish
the `Vec<Packet>` without cloning. The hand-written `Debug` that
printed `[N packets]` (because `ffmpeg_next::Packet` has no
`Debug`) now lives on the payload structs.
All three new variants also carry `#[from]`, joining `Ffmpeg`
which already had it — so `impl From<HwDeviceInitFailed> for Error`,
`impl From<AllBackendsFailed> for Error`, and
`impl From<FallbackFailed> for Error` are auto-generated, and
helpers returning `Result<_, HwDeviceInitFailed>` etc. can be
`?`-propagated into `Result<_, Error>` directly.

### Changed

- **`mediadecode` dep**: bumped to `0.2`.
- Boundary mapping in `pixel_format_from_ffmpeg` and the
side-data conversion paths updated to the new
`PixelFormat::Unknown(u32)` shape (17 fallback / assertion /
default-frame sites across `mediadecode-ffmpeg` and
`mediadecode-webcodecs`).

### Added

- **`Debug` impl for `Frame`** — manual `core::fmt::Debug` impl
showing dimensions / format so the only public type previously
without `Debug` is now printable.
Closes [issue #4 — finding 2](https://github.com/Findit-AI/mediadecode/issues/4).
- **`#[must_use]`** on every consuming `with_*` builder method
across the crate's public surface.
Closes [issue #4 — finding 3](https://github.com/Findit-AI/mediadecode/issues/4).

[0.1.0]: https://github.com/findit-ai/mediadecode/releases/tag/mediadecode-ffmpeg-v0.1.0
[0.2.0]: https://github.com/findit-ai/mediadecode/releases/tag/mediadecode-ffmpeg-v0.2.0

## [0.1.0] - 2026-05-09

Initial public release.
Expand Down Expand Up @@ -74,5 +139,3 @@ version-skewed decoder output:
rather than `linesize × plane_height_for(AVFrame.height)`, so
cropped or heavily aligned streams report correct byte counts.

[Unreleased]: https://github.com/findit-ai/mediadecode/compare/mediadecode-ffmpeg-v0.1.0...HEAD
[0.1.0]: https://github.com/findit-ai/mediadecode/releases/tag/mediadecode-ffmpeg-v0.1.0
2 changes: 1 addition & 1 deletion mediadecode-ffmpeg/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mediadecode-ffmpeg"
version = "0.1.0"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand Down
19 changes: 9 additions & 10 deletions mediadecode-ffmpeg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ Output frames are CPU-side, downloaded with `av_hwframe_transfer_data`

If every HW backend opens but later fails at decode time and the
software backend is also unavailable, the error surfaces as
`VideoDecodeError::Decode(Error::AllBackendsFailed { unconsumed_packets, .. })`
carrying any packets the decoder had already accepted from the demuxer
— so non-seekable callers (live streams, pipes, network sources) can
replay them through their own software decoder without re-demuxing.
`VideoDecodeError::Decode(Error::AllBackendsFailed(p))` carrying any
packets the decoder had already accepted from the demuxer (accessible
via `p.unconsumed_packets()` / `p.into_unconsumed_packets()`) — so
non-seekable callers (live streams, pipes, network sources) can replay
them through their own software decoder without re-demuxing.

## Usage

Expand Down Expand Up @@ -82,10 +83,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Probes HW backends in order, falls back to software.
let mut decoder = match FfmpegVideoStreamDecoder::open(stream.parameters(), time_base) {
Ok(d) => d,
Err(FfmpegError::AllBackendsFailed { unconsumed_packets, .. }) => {
Err(FfmpegError::AllBackendsFailed(p)) => {
// No backend at all could open this stream — including software.
// `unconsumed_packets` is empty at open-time. Caller decides.
let _ = unconsumed_packets;
let _unconsumed_packets = p.into_unconsumed_packets();
return Ok(());
}
Err(e) => return Err(e.into()),
Expand All @@ -98,14 +99,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {

match decoder.send_packet(&pkt) {
Ok(()) => {}
Err(VideoDecodeError::Decode(FfmpegError::AllBackendsFailed {
unconsumed_packets, ..
})) => {
Err(VideoDecodeError::Decode(FfmpegError::AllBackendsFailed(p))) => {
// Runtime exhaustion: rescued packets are the bytes the decoder
// already consumed from `input`. Replay them through your own
// software decoder before the current packet so non-seekable
// sources recover cleanly.
let _ = unconsumed_packets;
let _unconsumed_packets = p.into_unconsumed_packets();
return Ok(());
}
Err(e) => return Err(e.into()),
Expand Down
2 changes: 1 addition & 1 deletion mediadecode-ffmpeg/benches/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ fn bench_decode(c: &mut Criterion) {
}
Some(dec.backend())
}
Err(mediadecode_ffmpeg::Error::AllBackendsFailed { .. }) => None,
Err(mediadecode_ffmpeg::Error::AllBackendsFailed(_)) => None,
Err(e) => panic!("mediadecode-ffmpeg HW probe: {e}"),
}
};
Expand Down
5 changes: 3 additions & 2 deletions mediadecode-ffmpeg/examples/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {

let mut decoder = match VideoDecoder::open(stream.parameters()) {
Ok(d) => d,
Err(mediadecode_ffmpeg::Error::AllBackendsFailed { attempts, .. }) => {
Err(mediadecode_ffmpeg::Error::AllBackendsFailed(p)) => {
let attempts = p.attempts();
eprintln!(
"no hardware backend available; tried {} backend(s):",
attempts.len()
);
for (b, e) in &attempts {
for (b, e) in attempts {
eprintln!(" {b:?}: {e}");
}
eprintln!("(callers handle software fallback themselves — see ffmpeg::decoder::Video)");
Expand Down
21 changes: 12 additions & 9 deletions mediadecode-ffmpeg/src/boundary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ pub const fn from_av_pixel_format(raw: i32) -> PixelFormat {
x if x == AVPixelFormat::AV_PIX_FMT_BAYER_RGGB16LE as i32 => PixelFormat::BayerRggb16Le,
x if x == AVPixelFormat::AV_PIX_FMT_BAYER_GBRG16LE as i32 => PixelFormat::BayerGbrg16Le,
x if x == AVPixelFormat::AV_PIX_FMT_BAYER_GRBG16LE as i32 => PixelFormat::BayerGrbg16Le,
_ => PixelFormat::Unknown,
_ => PixelFormat::Unknown(raw as u32),
}
}

Expand Down Expand Up @@ -410,7 +410,7 @@ pub fn try_empty_video_frame() -> Option<VideoFrame<PixelFormat, VideoFrameExtra
];
Some(VideoFrame::new(
Dimensions::new(0, 0),
PixelFormat::Unknown,
PixelFormat::Unknown(0),
planes,
0,
VideoFrameExtra::default(),
Expand Down Expand Up @@ -511,7 +511,10 @@ mod tests {

#[test]
fn unknown_for_garbage_value() {
assert_eq!(from_av_pixel_format(-99_999), PixelFormat::Unknown);
assert!(matches!(
from_av_pixel_format(-99_999),
PixelFormat::Unknown(_)
));
}

#[test]
Expand All @@ -537,13 +540,13 @@ mod tests {
fn hw_formats_map_to_unknown_in_pixel_format() {
// HW sentinels intentionally don't have a mediadecode::PixelFormat
// representation — they're not CPU pixel data.
assert_eq!(
assert!(matches!(
from_av_pixel_format(AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32),
PixelFormat::Unknown,
);
assert_eq!(
PixelFormat::Unknown(_)
));
assert!(matches!(
from_av_pixel_format(AVPixelFormat::AV_PIX_FMT_VAAPI as i32),
PixelFormat::Unknown,
);
PixelFormat::Unknown(_)
));
}
}
Loading
Loading