Skip to content

feat(hang): captions as standalone text tracks - #2533

Open
kixelated wants to merge 6 commits into
mainfrom
claude/captions-implementation-3506a4
Open

feat(hang): captions as standalone text tracks#2533
kixelated wants to merge 6 commits into
mainfrom
claude/captions-implementation-3506a4

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Adds caption/subtitle support to hang as a standalone text catalog section: rendered in the browser and published via GStreamer.

Wire format

Captions are their own MoQ track, kept out of the video bitstream so the relay stays media-agnostic. One cue per group in the legacy container: the frame timestamp is the cue's start on the media clock, and the payload is a self-contained WebVTT segment. Formats are vtt (primary), utf8, and ttml; roles subtitle and caption. The section is omitted from the wire when empty, so caption-less catalogs stay byte-identical. Specified in draft-lcurley-moq-hang (## Text).

MSF was the starting point but only reserves the role vocabulary and an embedded-CEA accessibility field; it defines no standalone-text framing, so this invents it (and borrows role: subtitle|caption).

Watch (render)

A Text.Source / Text.Renderer pair parses cues with media-captions and draws them into an overlay above the canvas, scheduled against the same Sync clock as audio/video. It deliberately does not use <track>/TextTrack — playback is canvas-only (WebCodecs), so there's no <video> element to attach a text track to. A Captions tab in the settings UI toggles/selects the track.

Publish (GStreamer, not ffmpeg)

moqsink accepts a text/x-raw pad and publishes it as a hang text rendition: one WebVTT cue per group, built from the buffer's PTS + duration.

Why GStreamer and not the moq import fmp4 stdin path: ffmpeg (8.1.1) cannot mux subtitles into fragmented MP4. webvtt/wvtt is rejected by the muxer outright, mov_text + CMAF flags aborts on an assertion, and copying a pre-embedded track through the fragmenter silently drops it. GStreamer's qtdemux hands us decoded UTF-8 cues (text/x-raw) with PTS + duration already resolved, on the same clock as audio/video — so captions stay aligned with no separate pacing, no lead/loop knobs, and no in-band box parsing.

moq-mux's catalog gains the matching text field so the native path can carry it too.

Verified end-to-end

Published the captioned Big Buck Bunny (demuxed/big-buck-captions SRT, embedded as a soft mov_text track) through the real GStreamer pipeline into a live relay:

  • Catalog: video: 0.avc3, audio: 0.aac, text: 0.vtt (format vtt, role caption)
  • Live cue 00:00:35.916 --> 00:00:37.249 [BUNNY SNORES] against a playhead of 35.9s~16 ms alignment, because identity sync=true paces all three streams off one clock.

Watch-side rendering (renderer → CaptionsRenderer) was traced live: cues subscribe, parse, and commit to the overlay.

Follow-ups (not in this PR)

  • The captioned bbb.mp4 isn't uploaded to vid.moq.dev yet, so just pub gst bbb won't show captions until it is. just pub subtitles <name> <srt> embeds a track into a local test video (needs +faststart for multifilesrc looping).
  • just dev runs the ffmpeg bbb recipe, which can't carry captions; it would need to point at the gst recipe to show them by default.
  • In-band subtitle extraction from an fMP4/MKV container in moq-mux (still Error::UnsupportedSubtitle) is a separate, larger task.

🤖 Generated with Claude Code

(Written by Claude Opus 4.8)

Add a `text` section to the hang catalog for caption/subtitle tracks,
rendered in the browser and published via GStreamer.

Wire format: one cue per group in the legacy container (frame timestamp =
cue start on the media clock, payload = a self-contained WebVTT segment).
Formats are `vtt` (primary), `utf8`, and `ttml`; roles `subtitle` and
`caption`. The section is omitted when empty, so caption-less catalogs
stay byte-identical. Documented in draft-lcurley-moq-hang.

Watch: a Text.Source/Text.Renderer pair parses cues with media-captions
and draws them into an overlay above the canvas, scheduled against the
same Sync clock as audio/video (no <track>, since playback is canvas-only).
A Captions tab in the settings UI toggles/selects the track.

Publish: moqsink accepts a text/x-raw pad and publishes it as a hang text
rendition, one WebVTT cue per group from the buffer PTS + duration. The
GStreamer demux path is used because ffmpeg cannot mux subtitles into
fragmented MP4; qtdemux hands us decoded UTF-8 cues on the shared media
clock, so captions stay aligned with no separate pacing.

moq-mux's catalog also gains the `text` field so the native path can
carry it.

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@kixelated, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f6422b7e-6cf8-4bea-97e7-17b9ad029ada

📥 Commits

Reviewing files that changed from the base of the PR and between 5ddc7ad and 85522c8.

📒 Files selected for processing (1)
  • rs/moq-gst/src/sink/pad.rs

Walkthrough

Timed text support was added to the hang catalog with text formats, roles, rendition metadata, priorities, serialization, and public APIs. Broadcast and mux catalog paths now publish and preserve text renditions. GStreamer accepts subtitle buffers and emits timed WebVTT cues. The watch package derives available caption tracks, parses and renders VTT or UTF-8 cues, and adds caption selection controls and an overlay. Demo recipes and documentation describe caption publishing and consumption.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: standalone caption/text track support in hang.
Description check ✅ Passed The description is directly related to the changeset and describes the new text track, rendering, publishing, and compatibility behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/captions-implementation-3506a4

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

kixelated and others added 3 commits July 27, 2026 13:33
- moq-gst: box both `Sink` variants (clippy::large-enum-variant under
  -D warnings), and fix the four `push_buffer` test calls that still
  passed two arguments (E0061).
- watch: import the media-captions stylesheets (captions + regions) so
  cues are actually positioned/styled; without them the overlay rendered
  nothing visible. Mounted as a sibling of the overlay, since the renderer
  clears the overlay's children on every track change.
- watch: destroy() the CaptionsRenderer on cleanup so its ResizeObserver
  is disconnected (removing the overlay alone leaks it).
- watch: select the frame format from the rendition's container (legacy
  vs LOC) instead of always legacy; skip unsupported containers.
- hang: add the `jitter` field to TextConfig (Rust + JS) to match the
  draft's shared common fields.
- hang (js): a malformed `text` section now falls back to undefined
  rather than failing the whole catalog parse, so a catalog that used the
  key for something else still plays video/audio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ition guard

Follow-up review fixes on the standalone text-track captions work.

An unrecognized `role` value made the entire catalog fail to decode in Rust,
taking audio and video down with it, because `TextRole` was a closed serde
enum while `TextFormat` already preserved unknown values. The role vocabulary
is borrowed from draft-ietf-moq-msf and is expected to grow, so it now mirrors
`TextFormat`: an `Unknown(String)` variant that round-trips verbatim, with the
JS schema relaxed to a plain string the same way `format` already was. The
draft spells out that a consumer must not reject an unrecognized role.

moq-gst's subtitle pad inserted its catalog entry by hand instead of going
through moq-mux's rendition machinery, so it neither held the reservation that
gates the first catalog snapshot nor removed the entry on drop: a failed or
finished pad left the catalog advertising a track whose producer was gone. Add
`RenditionConfig for TextConfig` plus a `Reserved::text()` shorthand and hold
the guard for the pad's lifetime, exactly like the codec paths.

Cue text went into the WebVTT payload unescaped, so `<` opened a tag, a blank
line truncated a multi-line caption at the first paragraph break, and a `-->`
line read as another cue's timing. Escape the markup characters and drop blank
lines; an empty result publishes nothing.

The pad also declared every text track `role: caption`, asserting a
transcription of non-speech audio that a demuxed subtitle track gives no
evidence for. It now uses the `subtitle` default.

The demo gst recipe wired `demux.subtitle_0` unconditionally. gst-launch
builds every named branch up front, so on a file without subtitles that
queue never receives EOS and the pipeline will not shut down: Ctrl-C hangs.
Probe with ffprobe and only add the branch when there is a text track.
Switching the media branches back to parsebin also restores the codec
generality the hardcoded h264parse/aacparse dropped.

Watch-side, the renderer read groups strictly sequentially, so one stalled
group delayed every later cue; it now spawns a task per group like
`Container.Consumer` does. Cue insertion is order-independent (groups arrive
in delivery order, so the last-pushed cue is not necessarily the previous one
on the clock), and a VTT payload whose timing disagrees with its frame
timestamp warns once instead of misplacing every cue in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… playback

A second review pass over the caption path, all five findings reproduced
before fixing.

`text` was an ordinary application section before captions reserved the name,
and the new field deserialized eagerly, so any legacy value (a string, an
array, an object of another shape) failed the whole catalog and took audio and
video with it. Every shape tried failed, not just the exotic ones, because
`renditions` is a required field. Both catalog roots now decode the section
through `hang::catalog::deserialize_text`, which falls back to empty, matching
what the JS parser already did with `z.catch`.

Cue pruning shifted off the front of an array ordered by start time, not end
time, so a single long-running early cue stopped the sweep and let every
expired cue behind it accumulate for the life of the stream, with each commit
copying the whole array. It now sweeps the full array.

Captions were gated only on the element being connected, while the caption
clock runs off wall time: pausing left text scrolling over a frozen frame.
They now follow `paused` like audio and video do.

An empty `utf8` payload means "clear the caption" in the draft, but the
renderer returned on any empty payload before looking at the format, leaving
stale accessibility text on screen until the 30s linger expired. The clear is
now scheduled as its own event.

Text renditions declare a `jitter` that Sync never saw, since it summed audio
and video alone. A publisher whose cue flush delay exceeded the A/V buffer
delivered cues after their display window, so they were never shown. The text
source now exposes its jitter and Sync folds it into the buffer, which is why
it is constructed before Sync like the other two sources.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (7)
js/hang/src/catalog/index.ts (2)

16-16: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use the .ts extension in this new hang export.

This lower-level package requires extensions on relative imports. Change the new export to ./text.ts; do not extend the existing extensionless pattern in this changed line.

As per coding guidelines, lower-level packages such as hang must include .ts extensions in relative imports.

Proposed fix
-export * from "./text";
+export * from "./text.ts";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/hang/src/catalog/index.ts` at line 16, Update the new export in the
catalog index to use the explicit "./text.ts" relative path. Limit the change to
this export and preserve all existing exports unchanged.

Source: Coding guidelines


16-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Refresh the catalog module documentation.

The new export adds timed text types, but the module doc still describes only audio/video WebCodecs configurations. Update the doc block so the public entrypoint accurately describes the exported catalog surface.

As per coding guidelines, comments and documentation must describe the current code state, and entrypoints must include a top-level @module block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/hang/src/catalog/index.ts` at line 16, Update the catalog module’s
documentation near the public exports to add a top-level `@module` block and
describe the complete exported surface, including timed text types from the text
module alongside the existing audio/video WebCodecs configurations.

Source: Coding guidelines

js/hang/src/catalog/priority.ts (1)

3-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the exported priority map.

PRIORITY is a public TypeScript export whose surface now includes text, but it has no JSDoc. Add a concise description of the track-kind priorities.

As per coding guidelines, every exported JavaScript or TypeScript symbol and notable public member must be documented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/hang/src/catalog/priority.ts` around lines 3 - 8, Add concise JSDoc above
the exported PRIORITY constant describing that it maps track kinds, including
text, audio, and video, to their priority values. Keep the existing priority
entries and values unchanged.

Source: Coding guidelines

rs/moq-gst/src/sink/pad.rs (2)

375-380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update push_buffer's docstring for the new duration parameter.

The docstring wasn't updated when duration: Option<gst::ClockTime> was added. Its behavior is non-obvious: it's ignored for media frames but required (dropped-with-a-warning if None) for subtitle cues.

📝 Proposed doc addition
 	/// Import one buffer into the producer. A failed or producer-less pad drops the buffer; a timeline
 	/// drop is logged. A bad bitstream (or an oversized frame, rejected by moq-net) invalidates only this
 	/// pad.
+	/// `duration` is only meaningful for subtitle cues (an explicit cue end); it's ignored for media
+	/// frames, and a subtitle cue with no duration is dropped rather than pinned on screen forever.
 	/// Returns `true` the first time a buffer is dropped because the pad has no TIME segment, so the
 	/// caller can surface it once on the bus: without a timeline the pad can never publish.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-gst/src/sink/pad.rs` around lines 375 - 380, Update the documentation
for push_buffer to describe the duration parameter: media frames ignore it,
while subtitle cues require it and are dropped with a warning when it is None.

53-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why the container Frame.duration is intentionally omitted.

duration_micros is computed and embedded in the WebVTT cue's --> end timestamp, but Frame.duration is always None. That's presumably deliberate (the payload self-describes its end time; nothing downstream reads Frame.duration for the vtt format), but nothing here says so, inviting a future "fix".

📝 Proposed comment
 		self.producer.write(moq_mux::container::Frame {
 			timestamp: moq_net::Timestamp::from_micros(start_micros)?,
+			// The cue's end time is embedded in the WEBVTT payload itself (see the `-->` line
+			// above); the container-level duration is left unset since nothing reads it for `vtt`.
 			duration: None,
 			payload: Bytes::from(payload.into_bytes()),
 			keyframe: true,
 		})?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-gst/src/sink/pad.rs` around lines 53 - 76, Add an inline comment next
to `Frame.duration: None` in `write` explaining that the WebVTT payload embeds
the cue end time using `duration_micros`, so the container frame duration is
intentionally omitted for this format.
js/watch/src/text/renderer.ts (2)

153-170: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Front-only pruning can strand expired cues behind a longer one.

cues is sorted by startTime, not endTime. If an earlier-starting cue has a longer duration than one that follows it, the while (cues[0].endTime < cutoff) loop stops at index 0 and never reaches the later, already-expired cue — defeating the stated goal of bounding memory on a long stream.

♻️ Proposed fix: filter by endTime instead of shifting only the front
 				const cutoff = (now - PRUNE_BEHIND) / 1000;
-				let pruned = false;
-				while (cues.length > 0 && cues[0].endTime < cutoff) {
-					cues.shift();
-					pruned = true;
-				}
-				if (pruned) commit();
+				const before = cues.length;
+				for (let i = cues.length - 1; i >= 0; i--) {
+					if (cues[i].endTime < cutoff) cues.splice(i, 1);
+				}
+				if (cues.length !== before) commit();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/watch/src/text/renderer.ts` around lines 153 - 170, Update the pruning
logic in the tick callback so it removes every cue whose endTime is earlier than
cutoff, rather than only shifting expired cues from the front of the
startTime-sorted cues array. Preserve non-expired cues, set pruned when any cue
is removed, and call commit only when pruning occurred.

211-211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the microsecond-to-second conversion into a named constant.

1e6 is the only inline numeric timing literal in the file; PRUNE_BEHIND, UTF8_LINGER, and SKEW_TOLERANCE are all named constants. As per coding guidelines, "Avoid using magic numbers; use named constants instead."

♻️ Proposed fix
+// The scale of `sample.timestamp`: microseconds.
+const MICROS_PER_SECOND = 1e6;
+
 	async `#ingest`(
...
-		const start = (sample.timestamp as number) / 1e6;
+		const start = (sample.timestamp as number) / MICROS_PER_SECOND;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/watch/src/text/renderer.ts` at line 211, Replace the inline 1e6 divisor in
the timestamp conversion within the renderer with a descriptive module-level
named constant for the microsecond-to-second factor, alongside PRUNE_BEHIND,
UTF8_LINGER, and SKEW_TOLERANCE, and use that constant in the calculation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@doc/bin/cli.md`:
- Around line 102-105: Revise the documentation paragraph to scope the
fragmented MP4 subtitle limitation specifically to the `moq import` path, rather
than stating that ffmpeg cannot mux subtitles into fragmented MP4 generally.
Preserve the explanation that `moqsink` handles captions through the GStreamer
path and retain the existing GStreamer reference.

In `@drafts/draft-lcurley-moq-hang.md`:
- Line 383: Update the container section’s introductory wording to state that
audio, video, and text tracks use the container framing contract. Keep the
existing text payload and format definitions unchanged.
- Around line 263-267: Clarify the format documentation for vtt and ttml to
define the authoritative cue start timestamp and require embedded absolute
timing to match the containing frame timestamp. Update the corresponding
repeated wording noted at lines 283-284, preserving the existing utf8 semantics.

In `@rs/hang/src/catalog/root.rs`:
- Around line 420-428: The test unknown_text_role_keeps_the_catalog should also
serialize the parsed catalog and assert that the unrecognized “commentary” role
remains present verbatim in the serialized output. Keep the existing
catalog-preservation assertion and verify the rendition is not coerced or
omitted during reserialization.

---

Nitpick comments:
In `@js/hang/src/catalog/index.ts`:
- Line 16: Update the new export in the catalog index to use the explicit
"./text.ts" relative path. Limit the change to this export and preserve all
existing exports unchanged.
- Line 16: Update the catalog module’s documentation near the public exports to
add a top-level `@module` block and describe the complete exported surface,
including timed text types from the text module alongside the existing
audio/video WebCodecs configurations.

In `@js/hang/src/catalog/priority.ts`:
- Around line 3-8: Add concise JSDoc above the exported PRIORITY constant
describing that it maps track kinds, including text, audio, and video, to their
priority values. Keep the existing priority entries and values unchanged.

In `@js/watch/src/text/renderer.ts`:
- Around line 153-170: Update the pruning logic in the tick callback so it
removes every cue whose endTime is earlier than cutoff, rather than only
shifting expired cues from the front of the startTime-sorted cues array.
Preserve non-expired cues, set pruned when any cue is removed, and call commit
only when pruning occurred.
- Line 211: Replace the inline 1e6 divisor in the timestamp conversion within
the renderer with a descriptive module-level named constant for the
microsecond-to-second factor, alongside PRUNE_BEHIND, UTF8_LINGER, and
SKEW_TOLERANCE, and use that constant in the calculation.

In `@rs/moq-gst/src/sink/pad.rs`:
- Around line 375-380: Update the documentation for push_buffer to describe the
duration parameter: media frames ignore it, while subtitle cues require it and
are dropped with a warning when it is None.
- Around line 53-76: Add an inline comment next to `Frame.duration: None` in
`write` explaining that the WebVTT payload embeds the cue end time using
`duration_micros`, so the container frame duration is intentionally omitted for
this format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 791e0b23-7266-4dda-ad4a-e87243ee969c

📥 Commits

Reviewing files that changed from the base of the PR and between 5865208 and 8019af6.

⛔ Files ignored due to path filters (2)
  • bun.lock is excluded by !**/*.lock
  • js/watch/src/ui/icons/captions.svg is excluded by !**/*.svg
📒 Files selected for processing (32)
  • demo/pub/justfile
  • doc/bin/cli.md
  • doc/concept/layer/hang.md
  • drafts/draft-lcurley-moq-hang.md
  • js/hang/src/catalog/index.ts
  • js/hang/src/catalog/priority.ts
  • js/hang/src/catalog/root.ts
  • js/hang/src/catalog/text.ts
  • js/publish/src/broadcast.ts
  • js/publish/src/rendition.ts
  • js/watch/package.json
  • js/watch/src/element.ts
  • js/watch/src/index.ts
  • js/watch/src/text/index.ts
  • js/watch/src/text/renderer.test.ts
  • js/watch/src/text/renderer.ts
  • js/watch/src/text/source.ts
  • js/watch/src/ui/components/captions.ts
  • js/watch/src/ui/components/settings-panel.ts
  • js/watch/src/ui/icons.ts
  • js/watch/src/ui/state.ts
  • rs/hang/src/catalog/mod.rs
  • rs/hang/src/catalog/priority.rs
  • rs/hang/src/catalog/root.rs
  • rs/hang/src/catalog/text/format.rs
  • rs/hang/src/catalog/text/mod.rs
  • rs/moq-gst/src/sink/imp.rs
  • rs/moq-gst/src/sink/pad.rs
  • rs/moq-mux/src/catalog/consumer.rs
  • rs/moq-mux/src/catalog/hang/ext.rs
  • rs/moq-mux/src/catalog/mod.rs
  • rs/moq-mux/src/catalog/tracks.rs

Comment thread doc/bin/cli.md
Comment on lines +102 to +105
Subtitles ride in the source container. `moq import` reads media from stdin, and ffmpeg cannot mux a
subtitle track into fragmented MP4, so captions arrive over the GStreamer path instead: `moqsink`
accepts a decoded text pad and publishes it as a caption track. See
[GStreamer](gstreamer.md).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ffmpeg subtitle muxing mov mp4 fragmented documentation TTML WebVTT

💡 Result:

FFmpeg's support for muxing subtitles into fragmented MP4 (fMP4) varies by format, with recent advancements improving the ability to handle TTML [1][2]. TTML in Fragmented MP4: FFmpeg has implemented support for muxing TTML into fragmented MP4 (e.g., for DASH/ISMV workflows) [2][3]. Historically, this was restricted due to challenges in synchronizing subtitle-only fragments with other media tracks [1][3]. Recent updates have addressed these synchronization requirements, allowing TTML to be muxed into fragmented MP4 containers [2][3]. When using TTML in fragmented MP4, it is typically signaled with the stpp codec identifier [4]. WebVTT in Fragmented MP4: While FFmpeg can mux WebVTT into a standalone WebVTT file (outputting.vtt) [5][6], native muxing of WebVTT directly into a fragmented MP4 container is not a standard, primary feature of the ffmpeg CLI muxer in the same way as video or audio streams. In professional streaming workflows (such as generating MPEG-DASH or HLS), it is common practice to use specialized packaging tools to encapsulate WebVTT into the fragmented MP4 (using the wvtt codec identifier) [4][7][8]. Tools like Bento4 (mp4dash/mp4fragment) or GPAC (MP4Box) are frequently used in combination with FFmpeg to finalize the fragmented MP4 structure for DASH/HLS [7][9][8]. Summary for Documentation: 1. TTML: FFmpeg supports fragmented TTML muxing in MP4/ISMV [2]. Ensure that the subtitle stream is correctly mapped and appropriate time bases are set [2][3]. 2. WebVTT: For fragmented MP4 workflows, standard practice involves using external packagers (e.g., Bento4, MP4Box) to ingest WebVTT and wrap it into an fMP4 container [7][9][8], as native FFmpeg fragmented MP4 muxing is more optimized for media tracks (audio/video) [10]. For the most up-to-date and specific implementation details regarding fragmentation flags or codec tags, refer to the FFmpeg formats documentation and the specific FATE test suite references within the source code, which often provide working examples of complex muxing configurations [2][11][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate cli.md =="
fd -a '^cli\.md$' . | sed 's#^\./##'

echo "== relevant doc section =="
cli="$(fd '^cli\.md$' . | head -n1)"
if [ -n "${cli:-}" ]; then
  sed -n '90,115p' "$cli" | cat -n
fi

echo "== references to ffmpeg, moq import, fragmented/fmp4, subtitle/mux =="
rg -n "ffmpeg|moq import|fragmented|fmp4|fMP4|fragment|subtitle|capt|mux" doc src .github 2>/dev/null | head -n 120

Repository: moq-dev/moq

Length of output: 13502


Scope the fragmented MP4 subtitle limitation to moq import.

The current path does not preserve subtitle tracks when moq import writes fragmented MP4, so captions are handled via the GStreamer path. FFmpeg supports subtitle muxing into segmented/fragmented MP4 in some cases, so the absolute wording is too broad.

Proposed wording
-ffmpeg cannot mux a
-subtitle track into fragmented MP4, so captions arrive over the GStreamer path instead:
+the current `moq import` path does not preserve subtitle tracks in fragmented MP4, so captions
+arrive over the GStreamer path instead:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Subtitles ride in the source container. `moq import` reads media from stdin, and ffmpeg cannot mux a
subtitle track into fragmented MP4, so captions arrive over the GStreamer path instead: `moqsink`
accepts a decoded text pad and publishes it as a caption track. See
[GStreamer](gstreamer.md).
Subtitles ride in the source container. `moq import` reads media from stdin, and the current `moq import` path does not preserve subtitle tracks in fragmented MP4, so captions arrive over the GStreamer path instead: `moqsink`
accepts a decoded text pad and publishes it as a caption track. See
[GStreamer](gstreamer.md).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@doc/bin/cli.md` around lines 102 - 105, Revise the documentation paragraph to
scope the fragmented MP4 subtitle limitation specifically to the `moq import`
path, rather than stating that ffmpeg cannot mux subtitles into fragmented MP4
generally. Preserve the explanation that `moqsink` handles captions through the
GStreamer path and retain the existing GStreamer reference.

Comment on lines +263 to +267
The `format` field selects the cue serialization, and tells a consumer how to parse each frame's payload:

- `vtt`: WebVTT ([W3C WebVTT](https://www.w3.org/TR/webvtt1/)). Each payload is a self-contained `WEBVTT` segment whose cues carry absolute timing.
- `ttml`: TTML / IMSC1 ([W3C IMSC](https://www.w3.org/TR/ttml-imsc1.1/)) fragment (XML) with absolute timing.
- `utf8`: raw UTF-8 text with no embedded timing or styling. The cue is shown from its frame timestamp until the next cue; an empty payload clears it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Define the authoritative timestamp for VTT and TTML cues.

These lines define embedded absolute cue times while also making the outer frame timestamp the cue start, but never require them to agree or specify which one consumers use. Require the embedded timing to match the frame timestamp, or explicitly define one as authoritative. The current GStreamer writer emits the same start in both places (rs/moq-gst/src/sink/pad.rs:48-77).

Proposed clarification
-Each payload is a self-contained `WEBVTT` segment whose cues carry absolute timing.
+Each payload is a self-contained `WEBVTT` segment whose cue timing MUST match the enclosing frame timestamp.

Also applies to: 283-284

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@drafts/draft-lcurley-moq-hang.md` around lines 263 - 267, Clarify the format
documentation for vtt and ttml to define the authoritative cue start timestamp
and require embedded absolute timing to match the containing frame timestamp.
Update the corresponding repeated wording noted at lines 283-284, preserving the
existing utf8 semantics.

The remainder of the payload is codec specific; see the WebCodecs specification for specifics.

For example, h.264 with no `description` field would be annex.b encoded, while h.264 with a `description` field would be AVCC encoded.
For a text track, the remainder is the cue in the track's declared `format` (for example a `WEBVTT` segment).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include text in the container framing contract.

The container section still says that only audio and video tracks use a container, while this new rule defines the text payload inside the legacy container. Update that introductory wording to include text, otherwise implementations can treat text as outside the container rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@drafts/draft-lcurley-moq-hang.md` at line 383, Update the container section’s
introductory wording to state that audio, video, and text tracks use the
container framing contract. Keep the existing text payload and format
definitions unchanged.

Comment on lines +420 to +428
#[test]
fn unknown_text_role_keeps_the_catalog() {
// A future `role` value must not take down the whole catalog: audio and video have to keep
// playing even when a caption rendition is classified with a vocabulary we don't know yet.
let json = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"text":{"renditions":{"subs":{"format":"vtt","role":"commentary"}}}}"#;

let catalog = Catalog::from_str(json).expect("unknown role rejected the catalog");
assert_eq!(catalog.text.renditions.len(), 1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Assert that unknown roles survive serialization.

The draft requires unrecognized roles to be preserved verbatim, but this test only checks that one rendition exists. It would pass if "commentary" were coerced to "subtitle" or dropped during reserialization.

Proposed regression assertion
 		let catalog = Catalog::from_str(json).expect("unknown role rejected the catalog");
 		assert_eq!(catalog.text.renditions.len(), 1);
+		let output = catalog.to_json().expect("failed to reserialize");
+		assert!(output.contains(r#""role":"commentary""#));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn unknown_text_role_keeps_the_catalog() {
// A future `role` value must not take down the whole catalog: audio and video have to keep
// playing even when a caption rendition is classified with a vocabulary we don't know yet.
let json = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"text":{"renditions":{"subs":{"format":"vtt","role":"commentary"}}}}"#;
let catalog = Catalog::from_str(json).expect("unknown role rejected the catalog");
assert_eq!(catalog.text.renditions.len(), 1);
}
#[test]
fn unknown_text_role_keeps_the_catalog() {
// A future `role` value must not take down the whole catalog: audio and video have to keep
// playing even when a caption rendition is classified with a vocabulary we don't know yet.
let json = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"text":{"renditions":{"subs":{"format":"vtt","role":"commentary"}}}}"#;
let catalog = Catalog::from_str(json).expect("unknown role rejected the catalog");
assert_eq!(catalog.text.renditions.len(), 1);
let output = catalog.to_json().expect("failed to reserialize");
assert!(output.contains(r#""role":"commentary""#));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/hang/src/catalog/root.rs` around lines 420 - 428, The test
unknown_text_role_keeps_the_catalog should also serialize the parsed catalog and
assert that the unrecognized “commentary” role remains present verbatim in the
serialized output. Keep the existing catalog-preservation assertion and verify
the rendition is not coerced or omitted during reserialization.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
rs/hang/src/catalog/root.rs (1)

427-441: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Strengthen the legacy compatibility assertion.

The fixture has empty video and audio rendition maps, and the test only checks catalog.text.is_empty(). It would still pass if a regression discarded the sibling sections. Reuse a non-empty video/audio fixture and assert those renditions survive for each legacy shape.

As per coding guidelines, critical compatibility behavior should have regression coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/hang/src/catalog/root.rs` around lines 427 - 441, Strengthen the test
legacy_text_section_keeps_the_catalog by using non-empty video and audio
rendition fixtures instead of empty maps, then assert those rendition entries
remain present for every legacy text shape while retaining the assertion that
catalog.text is empty.

Source: Coding guidelines

js/watch/src/sync.ts (1)

34-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the exported SyncInput type.

SyncInput is an exported TypeScript symbol, but the public type has no type-level JSDoc. Add a concise doc comment above the declaration; the field comments do not document the public API as a whole.

As per coding guidelines, every exported TypeScript symbol must be documented.

Proposed documentation
+/** Inputs used to calculate the shared playback buffer. */
 export type SyncInput = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/watch/src/sync.ts` around lines 34 - 47, Add a concise type-level JSDoc
comment immediately above the exported SyncInput declaration to document the
public API as a whole, while preserving the existing field comments and type
definition.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@js/watch/src/sync.ts`:
- Around line 34-47: Add a concise type-level JSDoc comment immediately above
the exported SyncInput declaration to document the public API as a whole, while
preserving the existing field comments and type definition.

In `@rs/hang/src/catalog/root.rs`:
- Around line 427-441: Strengthen the test legacy_text_section_keeps_the_catalog
by using non-empty video and audio rendition fixtures instead of empty maps,
then assert those rendition entries remain present for every legacy text shape
while retaining the assertion that catalog.text is empty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c2de96d-74d2-48a9-8028-4250b29d6407

📥 Commits

Reviewing files that changed from the base of the PR and between 8019af6 and ff6ed03.

📒 Files selected for processing (8)
  • js/watch/src/element.ts
  • js/watch/src/sync.ts
  • js/watch/src/text/renderer.test.ts
  • js/watch/src/text/renderer.ts
  • js/watch/src/text/source.ts
  • rs/hang/src/catalog/root.rs
  • rs/hang/src/catalog/text/mod.rs
  • rs/moq-mux/src/catalog/hang/ext.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • rs/hang/src/catalog/text/mod.rs
  • js/watch/src/text/source.ts
  • rs/moq-mux/src/catalog/hang/ext.rs
  • js/watch/src/element.ts
  • js/watch/src/text/renderer.ts

kixelated and others added 2 commits July 27, 2026 19:14
The control bar gains a CC toggle next to the settings gear. It selects the
first text rendition when there is more than one (the Captions tab in the
settings panel is where a viewer picks between them) and drops out of layout
entirely when the broadcast publishes no text section, so a caption-less
stream gets no dead control.

It sets `style.display` rather than the `hidden` attribute: `.control` sets
`display: flex`, which beats the user-agent rule for `[hidden]`, so the button
stayed on screen at full size. The live badge already hides itself this way.

Also reverts the demo gst recipe's media branches to `h264parse`/`aacparse`.
Swapping them for `parsebin` was meant to restore codec generality but broke
video outright: parsebin preserves qtdemux's `stream-format=avc` caps behind
its own capsfilter, while the moqsink pad template requires `byte-stream`, so
the video pad never negotiated and the catalog published with no video
rendition. A bare `h264parse` converts because it negotiates against what is
downstream. The ffprobe gate around the subtitle branch is unaffected and
still does its job: bbb builds a two-queue pipeline with no sink_2, bbbcc a
three-queue one.

Verified end to end against a local relay: bbbcc publishes video 0.avc3,
audio 0.aac and text 0.vtt (format vtt, role subtitle), and cues render on the
overlay within ~130 ms of their media timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Toggling captions in the player re-anchored audio and video. The caption
rendition advertised a jitter, the watch element folds a text rendition's
jitter into the shared Sync buffer, and raising that floor makes the audio
ring reset() to refill while video holds longer: a visible reinitialization
from turning captions on.

The advertised value was meaningless. The estimator's jitter is the smallest
gap between consecutive frames, which for a codec is the frame duration and a
real flush delay, but for cues is just how close two subtitles happen to sit.
Big Buck Bunny has cues at 19.833s and 20.374s, so it published 541ms, and the
figure would keep shrinking (republishing the catalog) as tighter cue pairs
arrived. Each cue here is written and cut immediately, so an absent jitter
states the truth: flushed as produced.

The Sync wiring stays. Honoring a jitter a publisher deliberately declares is
still correct, and an absent field now contributes zero, so toggling captions
leaves the buffer untouched.

Verified against a local relay: the catalog carries no text jitter, the sync
buffer holds at 142ms across captions on and off, and cues still arrive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant