Repository navigation
feat(mux)!: import protected CMAF and signal it in both catalogs - #7916
michaelstuurman wants to merge 24 commits into
Conversation
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>
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>
|
Heads-up from the final API audit (moq-dev/moq#8088): to match the CLI's (Written by Claude Opus 5.5) |
|
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 (
Narrow the import surface:
Separately, the review recommends landing the consumer side before the cut (parse (Written by Claude Opus 5.5 with high) |
|
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 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) |
Closes #7766. Builds on kixelated/mp4-atom#268 (
encv/enca,sinf,psshas typed atoms).Problem
moq publish … import fmp4 --cmafrefused Common Encryption fMP4 (encv/enca), so DRM-packaged media (Shaka Packager, ffmpegcenc-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
encv/encaentry, typed through mp4-atom with no byte surgery, plus the moov'spsshboxes that apply to its key. The rendition'scodecis the onefrmawraps (avc1…,mp4a…). Each republished fragment keepssenc/saiz/saio, with its single CENCsaiopointed at the rebuiltsenc.drmmap of entries (system, scheme, key,pssh, license and certificate servers), and adrmlist of entry IDs on each video/audio rendition. A dangling ID is refused bycheck_renditionsin Rust andcheckRenditionsin js/hang, both covered by the shared parity corpus.drmis rendered as CMSFcontentProtectionsand per-trackcontentProtectionRefIDs, and parsed back in moq-mux and@moq/watch. moq-msf and@moq/msfrefuse a dangling or repeatedrefID.fmp4::Import::with_drm(system, drm::Servers { license, certificate }), or--drm-license SYSTEM[:TYPE]=URLand--drm-certificate SYSTEM[:TYPE]=URL. A system the media has nopsshfor is refused (UnusedDrmSystem).<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 (somoq 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.encv/encaentry counts as protected even when itsdrmis empty (catalog::hang::protected), so a third-party publisher that encrypts without signalling it is left out too. An MSF CMAF track with an encryptedinitDataand nocontentProtectionRefIDsis dropped on parse, like one whose entry is bad (decision 7).moq exportof 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
--cmaf: unwrapping would lose the per-sample IVs (ProtectedNeedsPassthrough).pssh(MissingPssh).sencits 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 PIFFuuidbox, which mp4-atom does not read (InvalidProtection("encrypted fragment without senc")).senc, or split over severalsaio/saiz(InvalidProtection). A fragment the importer drops samples from must also havesencandsaizcounts that match itstrun(InvalidProtection). A fragment kept whole passes through unchecked, because ffmpeg'scencaudio (Lavf 63, thecenc-clearkey.mp4fixture) repeats every earlier fragment's entries in eachsenc/saiz.psshin amoof, or aseigsample group instblortraf(KeyRotation). Other sample groups pass through, for example AACroll, which Shaka's cbcs audio carries.MultipleCodecs.cenc/cbcs, such ascens/cbc1(UnsupportedScheme), since CMSF allows only those two.another_scheme_is_refusedcovers the whitelist.Hang member names
refIDsystemdrmSystem.systemIDschemeschemekid(one)defaultKID(array)psshdrmSystem.psshlicensedrmSystem.laURLcertificatedrmSystem.certURLdrmcontentProtectionRefIDsEach 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
sencentries andsaizsizes are restated:sencis parsed usingtenc's per-sample IV size and the subsample flag, andsaiois 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), anda_mid_gop_start_restates_the_kept_samples_encryption(subsample maps, for the cenc and cbcs video fixtures). Maintainer: OK to drop that refusal?robustnessis 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).versionbump. The draft listsdrmamong the safe-to-ignore fields. Raisingversiononly whendrmis non-empty (astrackandwarmupraise 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 ignoresdrmstill gets the encrypted sample entry ininit, but no released player checks it."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.psshper track: a version-0psshapplies to every key. A version-1psshapplies only if its KID list names the track'sdefault_KID, or the list is empty. The init keeps only the applicable boxes. An entry takes the first box of its system.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'srefIDdrops the split entry instead of overwriting the other, and a repeatedrefIDis refused. In moq-mux a track lists only the split entry whose key its init'stencnames.@moq/watchlists all of them, which is enough to hide the rendition.cens, nodefaultKID, a malformed ID, URL orpssh) 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.uuidcrate (drm::Uuidre-exported) and accepts only the hyphenated form on the wire, so it matches zod'sz.guid(). MSF parsing accepts any formuuidparses, and@moq/watchaccepts the same forms.same_identity, since the init already differs. In@moq/watch,drmjoins the decoder identity and support-probe key.Impact
versionbump): an optional rootdrmmap and an optionaldrmlist on video and audio renditions. API:Catalog::drm,VideoConfig::drm,AudioConfig::drm, and thecatalog::drmmodule (Entry,Scheme,Server,Servers,Uuid,system::{WIDEVINE, PLAYREADY, FAIRPLAY, CLEARKEY, named}). New errors:Error::MissingDrm,Error::UnsupportedScheme.Catalog::media()copiesdrm.DrmSchema/Drm,DrmServerSchema/DrmServer,SYSTEM,MissingDrm,isProtected.RootSchemagainsdrmand the rendition schemasdrm, andcheckRenditionsrefuses a dangling ID.Catalog::content_protections,Track::content_protection_ref_ids, and the typesContentProtection,DrmSystem,ProtectionUrl.contentProtectionsbecomes a reserved root member. Parsing refuses a dangling or repeatedrefID.ContentProtectionSchema,DrmSystemSchema,ProtectionUrlSchemaand their types,Track.contentProtectionRefIDs, andCatalog.contentProtections.decoderefuses a dangling or repeatedrefID, andcontentProtectionsis a reserved root member.fmp4::mp4_atommoves to the release with Common Encryption's boxes.MoovandMoofgainpssh: Vec<Pssh>, andCodecgainsEncvandEnca. Migrate by addingpssh: Vec::new()toMoov/Moofliterals and matching the new variants (seeMIGRATE.md).fmp4::Error::ProtectedNeedsPassthroughinstead ofUnsupportedCodec. Every export leaves out a protected rendition.catalog::Selectdrops protected renditions before it selects, and fails a snapshot whose video and audio are all protected withError::Protected. MSF parsing drops a CMAF track whose init is encrypted but that lists no content protection.fmp4::Import::with_drm,catalog::hang::retain_clear,catalog::hang::protected,Error::Protected, andRenditionConfig::drm_mut(defaulted). Newfmp4::Errorvariants:InvalidProtection,UnsupportedScheme,ProtectedNeedsPassthrough,MissingPssh,KeyRotation,UnusedDrmSystem.decode::Decoder::newrefuses a protected rendition (UnsupportedCodec/Unsupported), and so do the decode consumers built on it (UnsupportedConfig).drmentries.moq … import fmp4 --cmaf --drm-license SYSTEM[:TYPE]=URL --drm-certificate SYSTEM[:TYPE]=URL. SYSTEM iswidevine|playready|fairplay|clearkey|<uuid>|urn:uuid:<uuid>, and each system may appear once per flag.moq exportfails when every rendition is protected.Video.Decoder.supportedandAudio.Decoder.supportedrefuse a rendition that listsdrmentries. MSF content protection maps onto hangdrm.Alternatives
encv/encain 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.versionbump whendrmis present. Old readers would drop the clear renditions too (decision 3).Follow-ups
psshin the export init andsenc/saiz/saioin its fragments, somoq export fmp4, moq-hls (withEXT-X-KEY/ContentProtection), and recordings can carry protected renditions instead of leaving them out.drm(andImport::with_drm(system, Servers),catalog::drm::Entry) to them is that quest.drm. The Rust tools also catch a CMAF init with anencv/encaentry and nodrm. Doing the same in@moq/watchwould cover a third-party hang publisher that encrypts without signalling it.z.base64()accepts non-canonical padding ("AB=="), which Rust's STANDARD engine refuses. That already holds for the CMAFinit, sopsshfollows it here. Aligning both is a separate change.[patch.crates-io]mp4-atomentry once Fix a race condition on subscribe #268 is released.Tests
rs/moq-mux/src/container/fmp4/test_data/:cenc-clearkey.mp4(40 KB): ffmpegcenc-aes-ctrwith a ClearKeypssh.cbcs-video.mp4(35 KB) andcbcs-audio.mp4(18 KB): Shaka Packager v3.9.3 with a ClearKeypsshand the public test KID, trimmed to init plus one or two fragments, withsidxdropped.saiopointing at the source's IVs, restating for edits and mid-GOP starts (both IV layouts, subsample maps), and every refusal above, including nosencand the scheme whitelist.retain_selectableand the fMP4 export leave a protected rendition out and keep its clear siblings. A selected export of only protected media fails withError::Protected, andSelectkeeps a held clear rendition over an enabled protected one. MSF parsing splits a multi-KID entry with normalised IDs, avoids a collision with a realrefID, and drops a bad entry and its tracks while clear tracks survive. It also drops an encrypted-init track with no content protection, whichretain_clearremoves from a hang catalog too.drmentries).drmsection and dangling references. moq-msf, @moq/msf: content protection round trip, dangling and repeatedrefIDrefusal. @moq/watch: protected renditions are unplayable, MSF content protection maps ontodrm, 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 withoutTYPE, and repeated-system refusal.moq-relaywith--drm-license clearkey=….encv/encawithpssh.saiopointed atsenc. cbcs audio has nosaio: it uses a constant IV and no subsamples.--cmaf.moq --listen … import fmp4 --cmaf -- export fmp4in one process.cenc-clearkey.mp4andcbcs-video.mp4now 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/libavbuild 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_cancelandmoq-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)