Skip to content
Merged
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,54 @@ All notable changes to this crate are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.1] May 21, 2026

### Added

- **`serde` feature** — optional `serde::{Serialize, Deserialize}` for the
whole descriptor vocabulary, gated behind `--features serde` (off by
default). The wire shape mirrors what storage backends already use, so a
serde-`json` value matches their representation:
- **Open** codec / format enums (`codec::{Video,Audio,Subtitle}Codec`,
`container::Format`, `subtitle::Format`,
`audio::{ChannelLayout, ContainerFormat}`) serialize as their canonical
`as_str()` slug — `VideoCodec::H264` ⇄ `"h264"`, `Other("x265")` ⇄
`"x265"` (no `{"Other": …}` wrapper).
- **`audio::SampleFormat`** — has BOTH an `Unknown(u32)` numeric escape
AND an `Other(SmolStr)` string escape, so it gets a bespoke impl rather
than the slug-only path. On **human-readable** formats (JSON / YAML /
…): named + `Other` values serialize as their `as_str()` string,
`Unknown(v)` as the bare numeric code `v`. On **non-human-readable**
binary formats (bincode / postcard / …): an explicit tagged
`{Code(u32), Slug(Cow<str>)}` wire enum, since `deserialize_any` is
unavailable there. All three arms round-trip losslessly on both.
- **Closed FFmpeg-coded enums with a lossless `Unknown(u32)` escape**
(`color::{Matrix, Primaries, Transfer, DynamicRange, ChromaLocation,
DcpTargetGamut}`, `pixel_format::PixelFormat`,
`frame::{Rotation, FieldOrder, StereoMode}`) and
`disposition::TrackDisposition` serialize as their `to_u32()` integer.
Round-trip is total: an unrecognised *code* deserializes to `Unknown(v)`.
These accept only integers — there is no slug form.
- **Strictly-closed coded enums (no `Unknown` arm)** —
`subtitle::TrackOrigin` (`Embedded`/`Sidecar`/`External`) and
`audio::BitRateMode` (`Cbr`/`Vbr`/`Abr`) — serialize as their `to_u32()`
integer but **reject unrecognised wire codes** as serde errors instead
of silently collapsing them to the default variant. Both expose a
`try_from_u32(v: u32) -> Option<Self>` method backing this behavior.
- **Plain structs** (`color::Info` and its HDR/mastering sub-structs,
`frame::{Dimensions, Rect, Rational, SampleAspectRatio, FrameRate}`,
`audio::{Loudness, Tags, Device}`… ) derive serde directly.
- **Validated structs** (`capture::GeoLocation`, `audio::Fingerprint`,
`audio::CoverArt`) route deserialize through their checking
constructors, so out-of-range / invariant-violating values are rejected
rather than materialised.
- **`lang::Language`** serializes as its canonical BCP-47 string
(`"en-US"`, `"zh-Hant-TW"`, `"und"`).
- Works at every capability tier: the no-alloc Copy types gain serde
under bare `--features serde`; the heap-tier types (codecs, formats,
audio metadata, capture, language) when paired with `alloc` / `std`
(forwarding `serde` to `smol_str` / `bytes`).

## [0.1.0] May 19, 2026

Initial `mediaframe` release — this crate is a **rename** of the
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ can all speak to without agreeing on anything heavier.
frame / HDR vocabulary so downstream proto schemas can extern-map
`.mediaframe.v1` → `::mediaframe`. Off by default — enable with
`--features buffa`.
- **`serde`** — optional `serde::{Serialize, Deserialize}` for the whole
descriptor vocabulary. Wire shape by type:
- Open codec / format enums (`codec::*`, `container::Format`,
`subtitle::Format`, `audio::{ChannelLayout, ContainerFormat}`) — the
`as_str()` slug, unknown slugs ride `Other`.
- FFmpeg-coded enums with an `Unknown(u32)` arm (colour, pixel-format,
frame coded enums, `TrackDisposition`) — the `to_u32()` integer;
unknown *codes* round-trip via `Unknown` (no slug form).
- Strictly-closed coded enums (`subtitle::TrackOrigin`,
`audio::BitRateMode`) — the `to_u32()` integer, but unknown codes are
**rejected** as serde errors rather than collapsing to the default.
- `audio::SampleFormat` (both `Unknown(u32)` and `Other(SmolStr)`) —
bespoke: human-readable formats emit a string for named/`Other` and a
number for `Unknown`; binary formats use a tagged `{Code, Slug}` wire.
- `lang::Language` — its BCP-47 string. Validated structs (`GeoLocation`
/ `Fingerprint` / `CoverArt`) deserialize through their checking
constructors.

Orthogonal to the capability tiers (no-alloc Copy types included). Off
by default — enable with `--features serde`.
- **`PixelSink`** + **`SourceFormat`** sealed traits re-exported at
the crate root.

Expand Down
24 changes: 23 additions & 1 deletion mediaframe/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[package]
name = "mediaframe"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/Findit-AI/mediaframe"
Expand All @@ -18,6 +18,16 @@ default = ["std"]
alloc = ["dep:smol_str", "dep:icu_locale_core", "icu_locale_core/alloc", "dep:bytes", "derive_more/unwrap", "derive_more/try_unwrap"]
std = ["alloc", "thiserror/default", "smol_str?/std", "bytes?/std"]
buffa = ["dep:buffa", "mediatime/buffa", "alloc"]
# Optional `serde` (de)serialization for the descriptor vocabulary.
# Orthogonal to the capability tiers: the no-alloc Copy types (colour /
# pixel-format / frame geometry / disposition) gain serde under bare
# `--features serde`; the heap-tier types (codecs, container/subtitle
# formats, audio metadata, capture, language) gain it when `alloc`/`std`
# is also on, forwarding `serde` to `smol_str` / `bytes` for their string
# / byte fields. The wire shape mirrors the storage backends: open
# codec/format enums serialize as their `as_str()` slug, closed
# FFmpeg-coded enums as their `to_u32()` integer.
serde = ["dep:serde", "smol_str?/serde", "bytes?/serde"]

frame = [
"yuv-planar", "yuv-semi-planar", "yuva", "yuv-packed", "yuv-444-packed",
Expand Down Expand Up @@ -51,6 +61,18 @@ icu_locale_core = { version = "2", default-features = false, optional = true }
bytes = { version = "1", default-features = false, optional = true }
buffa = { version = "0.6", default-features = false, optional = true }
smol_str = { version = "0.3", default-features = false, optional = true }
# `serde` is no-std by default (`default-features = false`); the `derive`
# feature pulls `serde_derive` for the struct/enum impls. No `alloc`/`std`
# serde feature is needed — string handling goes through borrowed-`&str`
# visitors and `smol_str` / `bytes` carry their own serde impls.
serde = { version = "1", default-features = false, optional = true, features = ["derive"] }

[dev-dependencies]
# Concrete serde formats for the `serde` round-trip tests (std-only).
# `serde_json` covers the human-readable path; `postcard` covers the
# non-self-describing binary path used by `SampleFormat`'s tagged wire.
serde_json = "1"
postcard = { version = "1", default-features = false, features = ["alloc"] }

[profile.bench]
opt-level = 3
Expand Down
14 changes: 14 additions & 0 deletions mediaframe/src/audio/bit_rate_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ impl BitRateMode {
_ => Self::Cbr,
}
}

/// Strict counterpart to [`Self::from_u32`]: returns `None` for any code
/// outside the enumerated set, instead of silently mapping it to the
/// default. Used by the strict deserialize path so adversarial / corrupt
/// wire values fail loudly rather than masquerading as `Cbr`.
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn try_from_u32(v: u32) -> Option<Self> {
match v {
0 => Some(Self::Cbr),
1 => Some(Self::Vbr),
2 => Some(Self::Abr),
_ => None,
}
}
}

#[cfg(test)]
Expand Down
33 changes: 33 additions & 0 deletions mediaframe/src/audio/cover_art.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,39 @@ pub struct CoverArt {
data: Bytes,
}

// Optional `serde` impls grouped in one gated `const` block: a single
// `#[cfg]` covers both directions, and the validate-on-deserialize shadow
// stays private to the block (no module-namespace pollution).
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
const _: () = {
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};

impl Serialize for CoverArt {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
let mut st = ser.serialize_struct("CoverArt", 2)?;
st.serialize_field("mime", &self.mime)?;
st.serialize_field("data", &self.data)?;
st.end()
}
}

// Routes deserialize through `try_new` so the non-empty `mime` / `data`
// invariants hold instead of being bypassed by a field derive.
#[derive(Deserialize)]
struct Shadow {
mime: SmolStr,
data: Bytes,
}

impl<'de> Deserialize<'de> for CoverArt {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = Shadow::deserialize(de)?;
CoverArt::try_new(s.mime, s.data).map_err(serde::de::Error::custom)
}
}
};

impl Default for CoverArt {
/// Synthetic `Default` — `mime: "application/octet-stream"`,
/// `data: [0u8]`. The public constructor [`Self::try_new`] still
Expand Down
33 changes: 33 additions & 0 deletions mediaframe/src/audio/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,39 @@ pub struct Fingerprint {
value: Bytes,
}

// Optional `serde` impls grouped in one gated `const` block: a single
// `#[cfg]` covers both directions, and the validate-on-deserialize shadow
// stays private to the block (no module-namespace pollution).
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
const _: () = {
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};

impl Serialize for Fingerprint {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
let mut st = ser.serialize_struct("Fingerprint", 2)?;
st.serialize_field("algorithm", &self.algorithm)?;
st.serialize_field("value", &self.value)?;
st.end()
}
}

// Routes deserialize through `try_new` so the non-empty-`algorithm`
// invariant holds instead of being bypassed by a field derive.
#[derive(Deserialize)]
struct Shadow {
algorithm: SmolStr,
value: Bytes,
}

impl<'de> Deserialize<'de> for Fingerprint {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = Shadow::deserialize(de)?;
Fingerprint::try_new(s.algorithm, s.value).map_err(serde::de::Error::custom)
}
}
};

impl Default for Fingerprint {
/// Synthetic `Default` — `algorithm: "default"`, `value: []`. The
/// public constructor [`Self::try_new`] still rejects empty
Expand Down
8 changes: 8 additions & 0 deletions mediaframe/src/audio/loudness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
///
/// `f32` storage precludes `Eq`/`Hash` (NaN ≠ NaN); the derives are
/// limited to `Debug`/`Clone`/`Copy`/`PartialEq`.
// `serde(default)` keeps sparse / older-schema JSON deserializable: missing
// fields fall back to the type-level `Default` impl — the all-zero
// "silent / fresh measurement" sentinel.
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Loudness {
integrated_lufs: f32,
Expand Down
8 changes: 8 additions & 0 deletions mediaframe/src/audio/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ use smol_str::SmolStr;
/// - Numeric fields use `Option<u16>` because `0` is a *valid*
/// value (year `0` exists historically; "track 0" sometimes
/// appears in test files), so the absent state must be distinct.
// `serde(default)` keeps sparse / older-schema JSON deserializable: missing
// fields fall back to the type-level `Default` impl (`Tags::new()` — all
// fields absent / empty), matching the absent-vs-empty convention above.
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Tags {
title: SmolStr,
Expand Down
8 changes: 8 additions & 0 deletions mediaframe/src/capture/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ use smol_str::SmolStr;
/// sentinel for "absent" so callers never need `Option<SmolStr>`
/// (matches the codec / source-tagging convention elsewhere in this
/// crate). Use [`Self::is_empty`] to detect the fully-absent state.
// `serde(default)` keeps sparse / older-schema JSON deserializable: missing
// fields fall back to the type-level `Default` impl (`Device::new()` — both
// `make` and `model` empty), matching the empty-string-means-absent convention.
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Device {
make: SmolStr,
Expand Down
37 changes: 37 additions & 0 deletions mediaframe/src/capture/geo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ pub struct GeoLocation {
altitude: Option<f32>,
}

// Optional `serde` impls grouped in one gated `const` block: a single
// `#[cfg]` covers both directions, and the validate-on-deserialize shadow
// stays private to the block (no module-namespace pollution).
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
const _: () = {
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};

impl Serialize for GeoLocation {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
let mut st = ser.serialize_struct("GeoLocation", 3)?;
st.serialize_field("lat", &self.lat)?;
st.serialize_field("lon", &self.lon)?;
st.serialize_field("altitude", &self.altitude)?;
st.end()
}
}

// Routes deserialize through `try_new` so out-of-range coordinates are
// rejected and a non-finite altitude is normalised, instead of being
// materialised directly by a field derive.
#[derive(Deserialize)]
struct Shadow {
lat: f64,
lon: f64,
#[serde(default)]
altitude: Option<f32>,
}

impl<'de> Deserialize<'de> for GeoLocation {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = Shadow::deserialize(de)?;
GeoLocation::try_new(s.lat, s.lon, s.altitude).map_err(serde::de::Error::custom)
}
}
};

impl Default for GeoLocation {
/// `(0.0, 0.0, None)` — "Null Island" with unknown altitude. This
/// is a legal in-range coordinate, the conventional sentinel for
Expand Down
6 changes: 6 additions & 0 deletions mediaframe/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ impl ChromaLocation {
/// RAW backends populate from clip-level color science and leave
/// `Unspecified` if absent. `Info::UNSPECIFIED` is the sensible
/// default for RAW backends that don't carry per-frame color data.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Info {
primaries: Primaries,
Expand Down Expand Up @@ -918,6 +919,7 @@ impl DcpTargetGamut {
/// This is clip / stream level (and frame-level when carried as
/// frame side data); the per-frame [`Info`] enums are
/// unchanged.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContentLightLevel {
max_cll: u32,
Expand Down Expand Up @@ -987,6 +989,7 @@ impl ContentLightLevel {
/// losslessly** rather than being silently saturated (Codex
/// adversarial-review F3). Validity is a separate concern from
/// preservation — see [`HdrStaticMetadata`].
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChromaCoord {
x: u32,
Expand Down Expand Up @@ -1062,6 +1065,7 @@ impl ChromaCoord {
/// - `max_luminance` / `min_luminance` are in units of **0.0001
/// cd/m²** (floating value = `raw / 10000.0`), matching FFmpeg's
/// `n/10000` `AVRational` luminance encoding.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MasteringDisplay {
display_primaries: [ChromaCoord; 3],
Expand Down Expand Up @@ -1185,6 +1189,7 @@ impl MasteringDisplay {
/// stays per-frame closed-form enums only; HDR10 static metadata is
/// clip / stream level and optional, so it lives in its own type.
/// (Dynamic HDR — HDR10+ / Dolby Vision RPU — is out of scope here.)
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HdrStaticMetadata {
mastering: Option<MasteringDisplay>,
Expand Down Expand Up @@ -1263,6 +1268,7 @@ impl HdrStaticMetadata {
///
/// All fields default to `0` (`#[derive(Default)]`), matching an
/// absent / unset configuration.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DolbyVisionConfig {
profile: u8,
Expand Down
Loading
Loading