Repository navigation
fix(msf): preserve caption and subtitle tracks across catalogs #5226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f28a8b6
0d6205a
47cf8b0
a0a268d
226e4e8
74e2cb7
2d74fe9
2485608
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ use std::str::FromStr; | |
| use std::task::{Poll, ready}; | ||
|
|
||
| use base64::Engine; | ||
| use hang::catalog::{AudioCodec, AudioConfig, Container, VideoCodec, VideoConfig}; | ||
| use hang::catalog::{AudioCodec, AudioConfig, Container, TextConfig, TextFormat, TextRole, VideoCodec, VideoConfig}; | ||
|
|
||
| use crate::Result; | ||
| use crate::catalog::hang::{Catalog, CatalogExt}; | ||
|
|
@@ -94,9 +94,11 @@ impl<E: CatalogExt> From<moq_net::track::Subscriber> for Consumer<E> { | |
| /// | ||
| /// Each MSF track is mapped onto a `hang::Catalog` rendition based on its [`moq_msf::Role`]: | ||
| /// video tracks become [`VideoConfig`] entries, audio tracks become [`AudioConfig`] entries. | ||
| /// Tracks with no role, with an unsupported role (caption, subtitle, sign language, audio | ||
| /// description, custom roles), or with packaging other than [`moq_msf::Packaging::Loc`], | ||
| /// [`moq_msf::Packaging::Cmaf`], or [`moq_msf::Packaging::Legacy`] are skipped with a warning. | ||
| /// Caption and subtitle tracks become text renditions; custom roles do too when their codec | ||
| /// identifies text. Unsupported text packaging is an error so a gateway cannot silently lose it. | ||
| /// Tracks with no role, sign language, audio description, other custom roles, and audio/video | ||
| /// with packaging other than [`moq_msf::Packaging::Loc`], [`moq_msf::Packaging::Cmaf`], or | ||
| /// [`moq_msf::Packaging::Legacy`] are skipped with a warning. | ||
| /// | ||
| /// [`moq_msf::Packaging::Loc`] and [`moq_msf::Packaging::Legacy`] map to their own containers: | ||
| /// they carry the same codec payload but frame it differently. | ||
|
|
@@ -145,6 +147,18 @@ pub(crate) fn from_msf<E: CatalogExt>(msf: &moq_msf::Catalog<E>) -> Result<Catal | |
| ); | ||
| } | ||
| }, | ||
| moq_msf::Role::Caption | moq_msf::Role::Subtitle => { | ||
| catalog | ||
| .text | ||
| .renditions | ||
| .insert(track.name.clone(), text_config_from_msf(track)?); | ||
| } | ||
| moq_msf::Role::Unknown(_) if matches!(track.codec.as_deref(), Some("vtt" | "ttml" | "utf8")) => { | ||
| catalog | ||
| .text | ||
| .renditions | ||
| .insert(track.name.clone(), text_config_from_msf(track)?); | ||
| } | ||
| other => { | ||
| tracing::warn!(track = %track.name, role = %other, "skipping MSF track with unsupported role"); | ||
| } | ||
|
|
@@ -157,9 +171,8 @@ pub(crate) fn from_msf<E: CatalogExt>(msf: &moq_msf::Catalog<E>) -> Result<Catal | |
| /// Decode the [`Container`] for a track based on its packaging and `init_data`. | ||
| /// | ||
| /// Returns `Ok(None)` when the packaging is unsupported (e.g. `MediaTimeline`, | ||
| /// `EventTimeline`, or an unknown variant). The caller skips these tracks with a warning | ||
| /// rather than failing the whole catalog, since unsupported packaging is a downstream | ||
| /// pipeline limitation, not a malformed catalog. | ||
| /// `EventTimeline`, or an unknown variant). Text conversion refuses it to prevent silent | ||
| /// loss; audio/video callers retain their existing skip policy. | ||
| /// | ||
| /// Returns `Err` when a CMAF track is missing or has malformed `init_data`. This is an | ||
| /// intentional hard error: a CMAF rendition is unusable without its `ftyp+moov` init | ||
|
|
@@ -210,6 +223,27 @@ fn legacy_description(track: &moq_msf::Track) -> Result<Option<bytes::Bytes>> { | |
| } | ||
| } | ||
|
|
||
| fn text_config_from_msf(track: &moq_msf::Track) -> Result<TextConfig> { | ||
| // A gateway must not silently delete text that its output is expected to preserve. | ||
| let container = container_from_msf(track)?.ok_or_else(|| Error::UnsupportedPackaging { | ||
| name: track.name.clone(), | ||
| packaging: track.packaging.to_string(), | ||
| })?; | ||
|
Comment on lines
+228
to
+231
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an MSF caption or subtitle uses AGENTS.md reference: rs/moq-mux/AGENTS.md:L8-L8 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disagree for this PR. CMAF is a valid hang text container, and a native hang publisher can already advertise CMAF text that the browser renderer skips, so refusing it in MSF conversion would make the gateway lossy for every other consumer to paper over one player limitation. The fix belongs in the player: offer only text renditions its renderer can show (or add a CMAF text demux). Suggesting that as a follow-up. (Written by Claude Opus 5.5) |
||
| let codec = track | ||
| .codec | ||
| .as_deref() | ||
| .ok_or_else(|| Error::MissingTextCodec(track.name.clone()))?; | ||
| // Keep unfamiliar codecs unfamiliar: a guessed WebVTT format could make a player render garbage. | ||
| let mut config = TextConfig::new(TextFormat::from_str(codec)?); | ||
| config.role = TextRole::from_str(&track.role.as_ref().unwrap().to_string())?; | ||
| config.lang = track.lang.clone(); | ||
| config.label = track.label.clone(); | ||
| config.container = container; | ||
| config.jitter = track.jitter; | ||
| config.delay = track.delay; | ||
| Ok(config) | ||
| } | ||
|
|
||
| fn video_config_from_msf(track: &moq_msf::Track) -> Result<Option<VideoConfig>> { | ||
| // Unsupported packaging (e.g. MediaTimeline) bubbles up as Ok(None) so the caller can | ||
| // skip the track with a warning rather than fail the whole catalog. | ||
|
|
@@ -455,6 +489,45 @@ mod test { | |
| track | ||
| } | ||
|
|
||
| #[test] | ||
| fn unsupported_text_packaging_is_refused_instead_of_lost() { | ||
| for packaging in [ | ||
| moq_msf::Packaging::MediaTimeline, | ||
| moq_msf::Packaging::EventTimeline, | ||
| moq_msf::Packaging::Unknown("future".into()), | ||
| ] { | ||
| for role in [ | ||
| moq_msf::Role::Caption, | ||
| moq_msf::Role::Subtitle, | ||
| moq_msf::Role::Unknown("commentary".into()), | ||
| ] { | ||
| let mut track = moq_msf::Track::new("text", packaging.clone()); | ||
| track.role = Some(role); | ||
| track.codec = Some("vtt".into()); | ||
| let error = from_msf(&moq_msf::Catalog::<()>::new(vec![track])).unwrap_err(); | ||
| assert!(matches!(error, crate::Error::Msf(Error::UnsupportedPackaging { .. }))); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_text_codec_is_required_and_unknown_formats_are_preserved() { | ||
| let mut track = moq_msf::Track::new("text", moq_msf::Packaging::Loc); | ||
| track.role = Some(moq_msf::Role::Caption); | ||
| assert!( | ||
| from_msf(&moq_msf::Catalog::new(vec![track.clone()])) | ||
| .unwrap_err() | ||
| .to_string() | ||
| .contains("missing codec") | ||
| ); | ||
| track.codec = Some("future-text".into()); | ||
| let catalog = from_msf(&moq_msf::Catalog::new(vec![track])).unwrap(); | ||
| assert_eq!( | ||
| catalog.text.renditions["text"].format, | ||
| TextFormat::Unknown("future-text".into()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn cmaf_video_yields_cmaf_container() { | ||
| // The complete init segment survives the MSF base64 conversion. | ||
|
|
@@ -702,7 +775,7 @@ mod test { | |
| #[test] | ||
| fn unsupported_role_is_skipped() { | ||
| let mut track = audio_track("caption0", moq_msf::Packaging::Legacy); | ||
| track.role = Some(moq_msf::Role::Caption); | ||
| track.role = Some(moq_msf::Role::AudioDescription); | ||
| let msf = moq_msf::Catalog::new(vec![track]); | ||
|
|
||
| let catalog = from_msf::<()>(&msf).expect("unsupported role should be skipped, not error"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a valid MSF text track is named
__proto__, assigning it into this ordinary object invokes the prototype setter rather than creating an own rendition property. If it is the only text track,Object.keys(textRenditions)remains empty and the caption track is silently omitted; with other tracks, the rendition map has an attacker-controlled prototype. Use a null-prototype record or another key-safe map.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 2485608: all three rendition records in
toHangnow use a null prototype, since video and audio had the same exposure. Regression test added.(Written by Claude Opus 5.5)