Skip to content

feat(mux)!: import protected CMAF and signal it in both catalogs - #7916

Closed
michaelstuurman wants to merge 24 commits into
moq-dev:mainfrom
michaelstuurman:feat/drm-import
Closed

michaelstuurman wants to merge 24 commits into
moq-dev:mainfrom
michaelstuurman:feat/drm-import

Conversation

@michaelstuurman

@michaelstuurman michaelstuurman commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #7766. Builds on kixelated/mp4-atom#268 (encv/enca, sinf, pssh as typed atoms).

Not mergeable as is: the workspace patches mp4-atom to the pushed head of kixelated/mp4-atom#268 (michaelstuurman/mp4-atom@10490e3, on 0.17.1) until it is released. Before merging, drop the [patch.crates-io] entry and bump mp4-atom to the release.

Problem

moq publish … import fmp4 --cmaf refused Common Encryption fMP4 (encv/enca), so DRM-packaged media (Shaka Packager, ffmpeg cenc-aes-ctr) could not be published. Neither catalog could say a rendition is encrypted, by which DRM system, or where a player gets a license. Without that signal, a consumer that cannot decrypt would select a protected rendition and feed ciphertext to its decoder.

Approach

  • Import (passthrough only). An encrypted track's per-track init keeps its encv/enca entry, typed through mp4-atom with no byte surgery, plus the moov's pssh boxes that apply to its key. The rendition's codec is the one frma wraps (avc1…, mp4a…). Each republished fragment keeps senc/saiz/saio, with its single CENC saio pointed at the rebuilt senc.
  • hang catalog. A root drm map of entries (system, scheme, key, pssh, license and certificate servers), and a drm list of entry IDs on each video/audio rendition. A dangling ID is refused by check_renditions in Rust and checkRenditions in js/hang, both covered by the shared parity corpus.
  • MSF catalog. hang drm is rendered as CMSF contentProtections and per-track contentProtectionRefIDs, and parsed back in moq-mux and @moq/watch. moq-msf and @moq/msf refuse a dangling or repeated refID.
  • License servers come from the publisher: fmp4::Import::with_drm(system, drm::Servers { license, certificate }), or --drm-license SYSTEM[:TYPE]=URL and --drm-certificate SYSTEM[:TYPE]=URL. A system the media has no pssh for is refused (UnusedDrmSystem).
  • Consumers that cannot decrypt. Nothing in the workspace decrypts, so every in-repo consumer treats a protected rendition as one it cannot decode, as the hang draft requires: <moq-watch> never selects it; the moq-mux exports (moq export, recordings) leave it out like a disabled rendition, dropping it before the selection runs; moq-hls neither lists nor holds it; moq-video/moq-audio decoders refuse it (so moq play, the FFI decoders, and transcode skip it like an unknown codec); transcode never picks it as the source; moqsrc and WebRTC egress drop it. The C and UniFFI bindings hide it from the catalog snapshots they hand out, so their media readers never reach it.
  • An encrypted init is never clear. In the Rust tools, a CMAF rendition whose init holds an encv/enca entry counts as protected even when its drm is empty (catalog::hang::protected), so a third-party publisher that encrypts without signalling it is left out too. An MSF CMAF track with an encrypted initData and no contentProtectionRefIDs is dropped on parse, like one whose entry is bad (decision 7).
  • Nothing clear left. moq export of a broadcast whose video and audio are all protected fails with "no rendition can be exported: every one is protected" (moq_mux::Error::Protected), rather than exiting 0 with nothing written.

Refused on import

  • Encrypted input without --cmaf: unwrapping would lose the per-sample IVs (ProtectedNeedsPassthrough).
  • An encrypted track with no applicable pssh (MissingPssh).
  • An encrypted fragment without the senc its samples need: per-sample IVs (tenc IV size > 0), or video, which Common Encryption protects with subsample maps. This also covers IVs only in a PIFF uuid box, which mp4-atom does not read (InvalidProtection("encrypted fragment without senc")).
  • CENC auxiliary information outside senc, or split over several saio/saiz (InvalidProtection). A fragment the importer drops samples from must also have senc and saiz counts that match its trun (InvalidProtection). A fragment kept whole passes through unchecked, because ffmpeg's cenc audio (Lavf 63, the cenc-clearkey.mp4 fixture) repeats every earlier fragment's entries in each senc/saiz.
  • Key rotation: a pssh in a moof, or a seig sample group in stbl or traf (KeyRotation). Other sample groups pass through, for example AAC roll, which Shaka's cbcs audio carries.
  • A clear lead: an encrypted entry beside a clear one, refused as MultipleCodecs.
  • Schemes other than cenc/cbcs, such as cens/cbc1 (UnsupportedScheme), since CMSF allows only those two. another_scheme_is_refused covers the whitelist.

Hang member names

"drm": {
  "1": {
    "system": "1077efec-c0b2-4d02-ace3-3c1e52e2fb4b",
    "scheme": "cbcs",
    "kid": "a1b2c3d4-e5f6-0718-293a-4b5c6d7e8f90",
    "pssh": "<base64 pssh box>",
    "license": { "url": "https://…", "type": "EME-1.0" },
    "certificate": { "url": "https://…", "type": "application/pkcs7-mime" }
  }
},
"video": { "renditions": { "video": { "…": "…", "drm": ["1"] } } }
hang CMSF
map key refID
system drmSystem.systemID
scheme scheme
kid (one) defaultKID (array)
pssh drmSystem.pssh
license drmSystem.laURL
certificate drmSystem.certURL
rendition drm track contentProtectionRefIDs

Each hang entry maps onto one CMSF entry. A CMSF entry with several defaultKIDs splits into one hang entry per key on parse (decision 6).

Decisions the quest left open

  1. Samples dropped from an encrypted track are restated, not refused. The quest lists "an encrypted track whose edit list drops samples" as a refusal. But Shaka (like ffmpeg and most packagers) starts AAC on an edit that skips the priming frame, so that refusal rejects every real-world packaged AAC track. The same restating covers the samples a mid-GOP start drops before the first sync sample. The kept samples' senc entries and saiz sizes are restated: senc is parsed using tenc's per-sample IV size and the subsample flag, and saio is re-pointed. Tests: an_edit_drops_the_skipped_samples_ivs (per-sample IVs), an_edit_restates_the_kept_samples_encryption (Shaka's constant-IV audio), and a_mid_gop_start_restates_the_kept_samples_encryption (subsample maps, for the cenc and cbcs video fixtures). Maintainer: OK to drop that refusal?
  2. robustness is left out, like the authorization URL. draft-ietf-moq-cmsf-01 §4.1.1.4.6 gives it no member name and no example uses it (checked 2026-11-04; draft-ietf-moq-cmsf-01 is the latest revision).
  3. No hang version bump. The draft lists drm among the safe-to-ignore fields. Raising version only when drm is non-empty (as track and warmup raise it to 4) would make old readers refuse the whole catalog, including its clear renditions. Not raising it means an old <moq-watch> selects a protected rendition and fails to decode it, like a disabled one. An older consumer that ignores drm still gets the encrypted sample entry in init, but no released player checks it.
  4. Entry IDs are the lowest free decimal string ("1", "2", …), assigned inside the shared catalog. Importers composed into one catalog share an entry only when it is identical: the same system, scheme, key, pssh, and servers, as the cbcs video+audio test shows. One that differs, such as a second importer with other servers for the same key, gets its own entry. An entry goes in with the rendition that lists it, in one catalog edit, and leaves with the last rendition that lists it. No catalog holds a dangling ID, or an orphaned entry from an init that failed.
  5. pssh per track: a version-0 pssh applies to every key. A version-1 pssh applies only if its KID list names the track's default_KID, or the list is empty. The init keeps only the applicable boxes. An entry takes the first box of its system.
  6. An MSF entry with several defaultKIDs maps to one hang entry per key, with ID {refID}:{kid}. The KID in the ID is lowercase hyphenated in both moq-mux and @moq/watch, whatever UUID form the MSF used (simple, hyphenated, braced, URN). A split ID that equals another entry's refID drops the split entry instead of overwriting the other, and a repeated refID is refused. In moq-mux a track lists only the split entry whose key its init's tenc names. @moq/watch lists all of them, which is enough to hide the rendition.
  7. A bad third-party CMSF entry (cens, no defaultKID, a malformed ID, URL or pssh) is dropped, together with the video and audio tracks it protects, in moq-mux and @moq/watch. The clear tracks survive. A malformed track config still fails the catalog, as before. The entry only affects its own tracks, and keeping a protected track without its entry would signal it as clear.
  8. UUIDs: hang uses the uuid crate (drm::Uuid re-exported) and accepts only the hyphenated form on the wire, so it matches zod's z.guid(). MSF parsing accepts any form uuid parses, and @moq/watch accepts the same forms.
  9. Protection is not part of same_identity, since the init already differs. In @moq/watch, drm joins the decoder identity and support-probe key.

Impact

  • hang (wire, additive, no version bump): an optional root drm map and an optional drm list on video and audio renditions. API: Catalog::drm, VideoConfig::drm, AudioConfig::drm, and the catalog::drm module (Entry, Scheme, Server, Servers, Uuid, system::{WIDEVINE, PLAYREADY, FAIRPLAY, CLEARKEY, named}). New errors: Error::MissingDrm, Error::UnsupportedScheme. Catalog::media() copies drm.
  • js/hang: DrmSchema/Drm, DrmServerSchema/DrmServer, SYSTEM, MissingDrm, isProtected. RootSchema gains drm and the rendition schemas drm, and checkRenditions refuses a dangling ID.
  • moq-msf (wire, CMSF draft-01): Catalog::content_protections, Track::content_protection_ref_ids, and the types ContentProtection, DrmSystem, ProtectionUrl. contentProtections becomes a reserved root member. Parsing refuses a dangling or repeated refID.
  • @moq/msf: ContentProtectionSchema, DrmSystemSchema, ProtectionUrlSchema and their types, Track.contentProtectionRefIDs, and Catalog.contentProtections. decode refuses a dangling or repeated refID, and contentProtections is a reserved root member.
  • moq-mux, breaking: the re-exported fmp4::mp4_atom moves to the release with Common Encryption's boxes. Moov and Moof gain pssh: Vec<Pssh>, and Codec gains Encv and Enca. Migrate by adding pssh: Vec::new() to Moov/Moof literals and matching the new variants (see MIGRATE.md).
  • moq-mux, behaviour: without passthrough, an encrypted file now fails with fmp4::Error::ProtectedNeedsPassthrough instead of UnsupportedCodec. Every export leaves out a protected rendition. catalog::Select drops protected renditions before it selects, and fails a snapshot whose video and audio are all protected with Error::Protected. MSF parsing drops a CMAF track whose init is encrypted but that lists no content protection.
  • moq-mux, API: fmp4::Import::with_drm, catalog::hang::retain_clear, catalog::hang::protected, Error::Protected, and RenditionConfig::drm_mut (defaulted). New fmp4::Error variants: InvalidProtection, UnsupportedScheme, ProtectedNeedsPassthrough, MissingPssh, KeyRotation, UnusedDrmSystem.
  • moq-c, moq-ffi, behaviour: a catalog snapshot leaves out protected renditions, so later renditions move down an index in moq-c. The ABI and API are unchanged.
  • moq-video / moq-audio: decode::Decoder::new refuses a protected rendition (UnsupportedCodec / Unsupported), and so do the decode consumers built on it (UnsupportedConfig).
  • moq-hls, moq-transcode, moq-gst (moqsrc), moq-rtc (egress): each skips protected renditions. A transcode derivative that references source renditions carries their drm entries.
  • moq-cli: moq … import fmp4 --cmaf --drm-license SYSTEM[:TYPE]=URL --drm-certificate SYSTEM[:TYPE]=URL. SYSTEM is widevine|playready|fairplay|clearkey|<uuid>|urn:uuid:<uuid>, and each system may appear once per flag. moq export fails when every rendition is protected.
  • @moq/watch: Video.Decoder.supported and Audio.Decoder.supported refuse a rendition that lists drm entries. MSF content protection maps onto hang drm.

Alternatives

  • Refuse edit lists and mid-GOP starts that drop samples (the quest's refusal). This rejects nearly all packaged AAC (decision 1).
  • Byte-patch encv/enca in the init instead of typed mp4-atom atoms. This is fragile and does not carry over to export. The PR uses mp4-atom#268 instead.
  • A hang version bump when drm is present. Old readers would drop the clear renditions too (decision 3).
  • Fail the MSF→hang conversion on a bad CMSF entry. One third-party entry would hide every clear rendition (decision 7).
  • Share entries by system, scheme, and key alone. A second importer's servers would be silently lost (decision 4).

Follow-ups

  • Faithful protected export: keep pssh in the export init and senc/saiz/saio in its fragments, so moq export fmp4, moq-hls (with EXT-X-KEY/ContentProtection), and recordings can carry protected renditions instead of leaving them out.
  • Browser playback (MSE/EME): quest/m3/drm-playback.
  • Bindings: quest/m2/drm/bindings.md. For now moq-c and moq-ffi hide protected renditions. Exposing drm (and Import::with_drm(system, Servers), catalog::drm::Entry) to them is that quest.
  • @moq/watch marks a rendition protected only by its drm. The Rust tools also catch a CMAF init with an encv/enca entry and no drm. Doing the same in @moq/watch would cover a third-party hang publisher that encrypts without signalling it.
  • zod's z.base64() accepts non-canonical padding ("AB=="), which Rust's STANDARD engine refuses. That already holds for the CMAF init, so pssh follows it here. Aligning both is a separate change.
  • Drop the [patch.crates-io] mp4-atom entry once Fix a race condition on subscribe #268 is released.

Tests

  • Fixtures in rs/moq-mux/src/container/fmp4/test_data/:
    • cenc-clearkey.mp4 (40 KB): ffmpeg cenc-aes-ctr with a ClearKey pssh.
    • cbcs-video.mp4 (35 KB) and cbcs-audio.mp4 (18 KB): Shaka Packager v3.9.3 with a ClearKey pssh and the public test KID, trimmed to init plus one or two fragments, with sidx dropped.
  • moq-mux import: drm signalling for cenc and cbcs, one shared entry across composed importers, separate entries for different servers, entry removal with the last rendition, the MSF round trip with servers, saio pointing at the source's IVs, restating for edits and mid-GOP starts (both IV layouts, subsample maps), and every refusal above, including no senc and the scheme whitelist.
  • moq-mux consumers: retain_selectable and the fMP4 export leave a protected rendition out and keep its clear siblings. A selected export of only protected media fails with Error::Protected, and Select keeps a held clear rendition over an enabled protected one. MSF parsing splits a multi-KID entry with normalised IDs, avoids a collision with a real refID, and drops a bad entry and its tracks while clear tracks survive. It also drops an encrypted-init track with no content protection, which retain_clear removes from a hang catalog too.
  • moq-c, moq-ffi: a catalog snapshot hides a protected rendition (moq-c with an encrypted CMAF init, moq-ffi with drm entries).
  • hang, js/hang: parity cases for the drm section and dangling references. moq-msf, @moq/msf: content protection round trip, dangling and repeated refID refusal. @moq/watch: protected renditions are unplayable, MSF content protection maps onto drm, split IDs match moq-mux, and a bad entry drops only its tracks. moq-hls, moq-transcode, moq-video, moq-audio: a protected rendition is skipped or refused. moq-cli: flag parsing, TYPE, urn:uuid: system IDs with and without TYPE, and repeated-system refusal.
  • End to end, at 109905b (before the last review round), all checks passing: the three fixtures published through a local moq-relay with --drm-license clearkey=….
    • Both catalogs carried the entries and the unwrapped codecs, and each init kept encv/enca with pssh.
    • In the fetched groups, saio pointed at senc. cbcs audio has no saio: it uses a constant IV and no subsamples.
    • Refused as expected: a widevine license on the ClearKey file, a repeated system, and the flag without --cmaf.
    • Export left the protected renditions out.
  • End to end, after this round: moq --listen … import fmp4 --cmaf -- export fmp4 in one process. cenc-clearkey.mp4 and cbcs-video.mp4 now exit 1 with "no rendition can be exported: every one is protected". A clear control (av1.mp4) exits 0 and writes its file.

Not run here: just ci (needs Nix), just drafts check (no kramdown-rfc), just test interop, the vitepress doc build (a duplicate TypeDoc install in this checkout), and moq-gst (no GStreamer here; the change is one call). Four js/hang and js/watch tests fail locally because the @moq/libav build fails here; they are unrelated to this change. So are two Rust tests that fail here before and after this change: moq-transcode rung::tests::cancelling_an_idle_rung_reports_cancel and moq-c video::tests::a_resume_onto_a_new_level_reserves_on_the_new_track. Browser playback (MSE/EME) is out of scope (quest/m3/drm-playback).

(Written with Claude Opus 5.5)

michaelstuurman and others added 22 commits November 4, 2026 17:12
Patch mp4-atom to kixelated/mp4-atom#268 until a release ships its
encv/enca, sinf and pssh atoms, and absorb what else changed there:
colr is a list per sample entry, and moov and moof carry their pssh
boxes. The color comes from the first colr with code points, so an ICC
profile beside them no longer hides them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog gains a root `drm` section keyed by entry ID. Each entry
names one DRM system, one Common Encryption scheme (cenc or cbcs) and
one default key, plus the system's pssh box and its license and
certificate servers, so it maps 1:1 onto a CMSF contentProtections
entry. A protected video or audio rendition lists its entries' IDs in
its own `drm` field, and check_renditions refuses one that names an
entry the root lacks, in Rust and in js/hang alike. The shared parity
corpus covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-msf reads and writes draft-ietf-moq-cmsf section 4: the root
contentProtections array and each track's contentProtectionRefIDs,
refusing a track that references an entry the root lacks, like a
missing initRef. The draft names no member for the authorization URL
or robustness, so neither is carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fmp4::Import with passthrough publishes cenc and cbcs media unchanged.
Each protected track's init keeps its encv/enca entry and the moov's
pssh boxes that apply to its key, the rendition's codec is the one frma
names, and the catalog's drm section gets one entry per DRM system,
scheme and key, shared by tracks under the same key. Import::with_drm
sets a system's license and certificate servers, and a system no
encrypted track carries is refused.

Re-encoding a fragment moves its senc, so saio is pointed at the
rebuilt one. An edit list that drops samples, such as Shaka's AAC
priming edit, restates senc and saiz for the kept samples.

Refused rather than published undecryptable: encrypted input without
passthrough, an encrypted track without a pssh, encryption information
outside senc or over several saio, key rotation (pssh in a moof, or a
seig sample group; roll groups pass), a clear lead, and schemes other
than cenc and cbcs.

The MSF catalog renders the drm section as CMSF contentProtections and
contentProtectionRefIDs, and parsing one maps them back; an entry with
several default keys becomes one hang entry per key.

Fixtures: the ClearKey cenc file (ffmpeg plus a ClearKey pssh) and
Shaka Packager 3.9.3 cbcs video and audio, trimmed to their first
fragments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`moq import fmp4 --cmaf` takes --drm-license and --drm-certificate,
each SYSTEM=URL with SYSTEM a well-known name (widevine, playready,
fairplay, clearkey) or a system ID, and repeatable per system. They
reach the catalog's drm entries through fmp4::Import::with_drm, which
refuses a system the media carries no pssh for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@moq/msf reads and writes the root contentProtections array and each
track's contentProtectionRefIDs, refusing a track that references an
entry the root lacks, as moq-msf does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebCodecs cannot decrypt, so the video and audio decoders' support
probes refuse a rendition that lists drm entries, and <moq-watch> never
selects it; a protected rendition joins the decoder identity. An MSF
catalog's content protection maps onto the hang drm section, so a
protected MSF track is hidden the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The moq-hang draft defines the root drm map and the rendition drm field
and their mapping onto CMSF contentProtections; the catalog concept page
and the CLI page cover importing Common Encryption fMP4 with license
servers, and why <moq-watch> does not play it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fragment whose samples need a senc (per-sample IVs, or encrypted video's
subsample maps) and carries none, or only PIFF's uuid box, was republished
undecryptable. Refuse it as InvalidProtection.

Also drop the never-built Error::ProtectedEdit, name the sample in a
senc/saiz that lacks one (ProtectionLacksSample), check saiz's sample count
when restating, and test restating a mid-GOP start and the scheme whitelist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with its last rendition

register_drm shared an entry by system, scheme and key alone, so a second
importer under the same key but with other servers or another pssh was
signalled with the first one's. It now shares only an identical entry and
gives any other its own ID. Removing a video or audio rendition removes the
drm entries no rendition lists any more. hang's Entry::same_key, now unused,
goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exports kept a protected rendition's encv/enca entry but dropped its
pssh, senc, saiz and saio, and the players fed its ciphertext to decoders.
Nothing in the workspace decrypts, so each consumer now treats a rendition
listing drm entries as one it cannot decode, as the hang draft requires:

- moq-mux: Source::retain_selectable drops it, as it does a disabled one,
  for every export (moq export, recordings). catalog::hang::retain_clear
  does it for other consumers.
- moq-hls: neither listed nor held.
- moq-video and moq-audio: Decoder::new refuses it as unsupported, which
  moq play, the FFI decoders and transcode report like an unknown codec.
- moq-transcode: never the source; the derivative carries the drm entries
  of the source renditions it references.
- moq-gst source and moq-rtc egress: left out of what they forward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… across Rust and JS

One CMSF entry the hang catalog cannot carry (a cens scheme, no defaultKID,
a malformed ID, URL or pssh) failed MSF to hang for the whole catalog, so
its clear renditions vanished too. moq-mux and @moq/watch now drop just
that entry and the video and audio tracks it protects, with a warning in
Rust. moq-mux's InvalidContentProtection, no longer returned, goes.

A split entry's {refID}:{kid} could also overwrite a real refID. moq-msf
and @moq/msf refuse a repeated refID, and a split ID that is another
entry's refID drops the split entry. @moq/watch now parses system and key
IDs in every form moq-mux's uuid parse takes and writes them lowercase
hyphenated, so both sides produce the same IDs and keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and take a server type

A second --drm-license (or --drm-certificate) for the same system silently
replaced the first. It is now refused. SYSTEM:TYPE=URL gives the server's
protocol or the certificate's MIME type, which the catalog carries as the
server's type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…AF-only wording

The concept doc names the Rust consumers that leave a protected rendition
out and uses "video" as the example rendition ID; the CLI doc gives the
SYSTEM:TYPE form. The hang draft, hang, and js/hang no longer say only a
CMAF rendition can be protected, which nothing enforces, and note that a
multi-key CMSF entry splits on parse. Renditions under one key MAY share
an entry, since different servers now take their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fMP4 importer added a protected rendition's drm entries in one catalog
edit and the rendition listing them in a later one. Another importer's
rendition leaving in between released the new entries, so the rendition
then listed missing ones: every later emit failed check_renditions and
closed the catalog for every importer. An init failing after the entries
went in left them behind.

Rendition::set_protected now adds the entries, shared when identical, and
the rendition in one guard, and releases them again if the rendition
fails to write. RenditionConfig gains drm_mut, None by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restating a fragment's encryption information checks the senc and saiz
sample counts against the trun first, and mp4-atom decodes a saiz with
one size per counted sample, so a kept sample always has its entry and
ProtectionLacksSample could never be returned. It goes; the defensive
lookups report InvalidProtection.

A fragment kept whole still passes its senc and saiz through unchecked:
ffmpeg's cenc audio (Lavf 63) carries every earlier fragment's entries
in each senc and saiz, and holding the counts to the trun there would
refuse ffmpeg's own output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An MSF CMAF track whose initData is encrypted (encv/enca) but that lists
no contentProtectionRefIDs became a clear rendition, so exports wrote an
encv init without pssh or senc and decoders got ciphertext. from_msf now
drops such a track, as it does one whose content protection it cannot
carry.

catalog::hang::protected counts a CMAF rendition whose init holds an
encrypted sample entry as protected even when its drm is empty, and
retain_clear uses it, so a third-party hang catalog that encrypts without
signalling it is left out as well. retain_clear warns once per rendition
and logs repeats at debug level, rather than warning on every snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one is clear

catalog::Select and moq-gst's source applied the selection before
retain_clear, so a held clear rendition could be passed over for an
enabled protected one, which retain_clear then removed, leaving nothing.
Both now drop the protected renditions first.

A snapshot whose video and audio are all protected fails the selection
with the new Error::Protected. moq export of such a broadcast exited 0
having written nothing; it now fails with "no rendition can be exported:
every one is protected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The C and UniFFI media consumers listed a protected rendition under the
codec it wraps, with nothing marking it protected, and their readers then
handed out encrypted samples that had lost their IVs. Each catalog
snapshot they see now goes through retain_clear (moq-c) or the same
protected test (moq-ffi's convert_catalog), so a protected rendition is
absent, as the hang draft requires of a consumer that cannot decrypt.
Exposing the drm section to the bindings is left to a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tificate

SYSTEM was split at its first colon to find the TYPE, so
urn:uuid:edef8ba9-...=URL parsed as a system named urn and was refused.
A urn:uuid: prefix is now part of SYSTEM, with or without :TYPE after
it. The validated servers are taken with expect rather than silently
defaulting to none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
michaelstuurman and others added 2 commits November 5, 2026 08:54
Takes moq-dev#7918 (mp4-atom 0.17.1) and the rest of main.

- mp4-atom: main's 0.17.1 colr handling (every colr entry fills gaps)
  replaces this branch's first-CICP-entry version; 0.17.1 already makes
  colr a Vec, so only pssh and Encv/Enca remain this branch's churn.
- The [patch.crates-io] fork (mp4-atom#268) has every 0.17.1 fix but
  says 0.17.0, so the workspace asks for 0.17.0 until moq-dev#268 is released.
- New Moof literal in the cmaf-receive bench gains pssh.
- workspace-hack: main's file with this branch's uuid serde feature.
- MIGRATE notes and test modules from both sides kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator

Heads-up from the final API audit (moq-dev/moq#8088): to match the CLI's --cmaf, it renames moq_mux::container::fmp4::Import::with_passthrough to with_cmaf and moq_hls::import::Config.passthrough to cmaf, with the same meaning (true publishes whole CMAF frames). This PR adds three with_passthrough(..) callers (the import dispatch, the two protected-import paths, and the test configure), plus a self.passthrough field read. Whichever lands second needs those renamed. The audit also narrows @moq/hang/catalog's Audio/Video namespaces to { ranked }, but this PR doesn't use the removed names.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator

An API review of the open PRs (moq-dev/moq#8214) scored this PR's catalog wire 5/10 and its import API 6/10. The design is right, but two parts would be costly to change once shipped:

Make DRM entries fail soft. At this head, one entry that a reader can't parse refuses the whole hang catalog, in both Rust and zod, clear renditions included. That happens with an unknown scheme (cens/cbc1 if CMSF widens), a UUID without hyphens, or a license URL that z.url() rejects. It contradicts decision 3 (no version bump, so old readers keep clear renditions) and decision 7 (a bad CMSF entry drops only its tracks). The fix:

  • Make Scheme #[non_exhaustive] with Other(String) (zod: z.string()).
  • Treat a rendition whose entry is unknown or unparseable as protected and unselectable.
  • Keep refusing a dangling ID only in check_renditions, for catalogs built by hand.

Narrow the import surface:

  1. Hold drm: BTreeMap<Uuid, Servers> in the importer's config instead of a with_drm call per system. Move Servers and Server into moq_mux::container::fmp4, so the hang catalog keeps only wire types.
  2. Fold the six new fmp4::Error protection variants into one Protection(String), since callers never match on errors.
  3. Make retain_clear and protected pub(crate) unless a crate outside moq-mux calls them.
  4. Drop :TYPE from --drm-license/--drm-certificate for now; its : collides with urn:uuid:.
  5. Record the moq-c index shift for protected renditions in MIGRATE.md as a behavior break.
  6. Rebase onto with_cmaf (moq-dev/moq#8088 renames with_passthrough).

Separately, the review recommends landing the consumer side before the cut (parse drm, skip protected renditions, count encv/enca as protected), so players from the cut refuse protected content instead of failing on it.

(Written by Claude Opus 5.5 with high)

@kixelated

Copy link
Copy Markdown
Collaborator

Thank you for this work, and for the prototype in #7766 that showed protected CMAF playing over MoQ.

We're closing the content protection work until a consumer needs it. The project's player decodes through WebCodecs, which cannot decrypt; EME works only through MSE, which <moq-watch> no longer has; and native players have no CDM. So nothing in this repository could use a drm catalog section yet, and we'd rather not add lasting catalog and spec surface ahead of a consumer. The quests for it (quest/m3/drm, quest/m5/drm-playback) are being deleted for the same reason.

If you or anyone else ends up needing protected CMAF over MoQ, please open an issue describing the player and the license setup. We'd revisit it then, starting from these branches, including whether plain CMAF box passthrough without hang catalog signalling would be enough.

(Written by Claude Opus 5.5 with high effort)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

moq-mux, moq-msf: encrypted (CENC) CMAF is refused, and the MSF catalog can't signal CMSF content protection

2 participants