Skip to content

fix(cc608): flip each caption on its cue's first frame, with a -sc opt-out - #326

Closed
tobbee wants to merge 5 commits into
mainfrom
fix/cc608-cue-boundary-flip
Closed

fix(cc608): flip each caption on its cue's first frame, with a -sc opt-out#326
tobbee wants to merge 5 commits into
mainfrom
fix/cc608-cue-boundary-flip

Conversation

@tobbee

@tobbee tobbee commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #325.

What & why

A pop-on CTA-608 caption is two transmissions: a build (RCL + ENM + rows) written into non-displayed memory,
and an EOC that flips it on screen. Both drain at one 608 pair per frame, so where the build sits decides when
the caption appears.

generate.BuildUnitCues starts the build at its own cue's first frame and lets the EOC follow, so — as its own
comment notes — the flip lands at start+pairs. For the two-row clock cue (~16-19 pairs) that is 0.6-0.75 s
into the very second the caption names
, and the caption then straddles the boundary into the next cue. The
timestamp and segment number visibly lagged the picture.

This places each cue's EOC on its cue's first frame and transmits the build over the frames immediately
before it, so the flip coincides with the cue boundary and a caption is displayed over exactly the interval its
text names.

That accuracy costs self-contained segments, which is a genuine trade rather than a strict improvement, so the
previous behaviour stays reachable per stream: timecc608_CC1-eng-sc keeps every caption inside the segment
that carries it. The default is the fix; -sc is the opt-out. See
Opting out below.

How

cc608UnitFrames drives go-608's schedule.Scheduler directly instead of calling BuildUnitCues. The
scheduler's FIFO is gated by eligibility time, so pushing build-then-EOC keeps the 608 byte stream ordered and
lands the flip on the intended frame. Everything used is already exported by go-608 v0.6.0 — no dependency bump.

Two consequences, both deliberate:

  • Captions span unit boundaries. A fragment carries the build for the first cue of whatever follows it — the
    next fragment, or the next segment for a final fragment — which is why the next unit's segment number is now
    passed down through injectCC608. A client that starts, seeks, or joins mid-stream receives a leading EOC
    without the build that belongs to it, and what it shows for that cue period depends on its decoder: a fresh 608
    decoder has nothing loaded and shows no caption, while one that keeps 608 state across the discontinuity
    flips whatever was last preloaded and can show one cue of a stale caption. Either way it corrects at the next
    cue boundary, typically within a second. Within this mode the server cannot avoid it — any pair sent ahead of
    the EOC to clear the state would erase the build about to be flipped — which is why the behaviour is selectable
    rather than simply replaced: a client that needs segment-independent captions asks for -sc. This is a real
    change for seeking clients, and it is the argument for the open question in CTA-608 captions are displayed ~0.6-0.76 s after the time they name #325 about skipping a segment's
    first cue instead of flipping it.
  • Each cue is encoded with a fresh cta608.Encoder (in both modes), so a build always fully describes its
    screen. This is what lets independently generated, on-demand segments line up: the build in segment N's tail and
    the flip at segment N+1's first frame come from separate requests and must agree without shared encoder state.
    It is also what lets a receiver joining mid-unit get a whole caption rather than one row of one.

A build that cannot fit between the previous flip and its own is now an explicit error rather than a silently
dropped build (which would leave an EOC with nothing loaded, i.e. a caption that never appears).

The 608 data rate is unchanged at one pair per frame, so cc_count stays round(600/fps) and the stream remains
in line with the A/53 / SCTE-128 rate model. The alternative — bursting a whole cue into the cue's first frame —
was rejected for that reason, and because consumers that trust parser-reported cue times would mistime it: the
SVTA @svta/cml-608 parser (the dash.js/hls.js decoder) advances its internal clock ~33.4 ms per byte pair
within one addData call, so a ~19-pair burst reads as ~0.6 s late.

The README's timecc608 description now covers where a cue's build and flip sit and what that costs at a
discontinuity; the CHANGELOG entry is a short summary pointing there.

Opting out: timecc608_CC1-eng-sc

Because the mid-stream-join cost above is a real trade rather than a strict improvement, the previous behaviour is
kept available per stream. The timecc608 grammar becomes <channel>-<lang>[-sc]:

  • timecc608_CC1-eng (default) — flip on the cue's first frame. Frame-accurate; captions span the segment boundary.
  • timecc608_CC1-eng-sc — self-contained: a cue's build and its flip ride the cue's own frames, so every
    segment is independently decodable and a client starting, seeking or joining anywhere sees a whole caption at
    once. The cost is that each caption appears ~0.5 s into the second its clock names (measured: flips at frames 15
    and 45 for cue boundaries 0 and 30 at 30 fps).

A trailing sc counts as the flag only when a language precedes it, so a hyphenated language tag still parses:
CC1-sc is the Sardinian tag, CC1-sc-sc is that language self-contained, CC1-en-US-sc works, and CC1-eng-xx
stays the language eng-xx. Only a tag whose final subtag is literally sc cannot be expressed.

cc608UnitFrames takes a cc608FlipMode and keeps the fresh-encoder-per-cue rebuild in both modes, rather
than delegating the two behaviours to go-608 v0.7.0's generate.BuildUnitCues with and without
WithFlipAtCueStart. BuildUnitCues shares one cta608.Encoder across a unit's cues, so cues after the first are
transmitted as diffs; a receiver joining mid-unit would then flip a diff and see one row of a two-row caption — and
no test would catch it, since a decoder that saw cue 0 applies the diff correctly. Adopting the upstream helper
remains possible, just not without giving up that property.

/urlgen documents the option and its trade-off. The field itself needed no change: it is free text validated
through CreateCC608Config.

Testing

go test ./... and golangci-lint are green. The caption tests now decode two consecutive units as one
stream
, which is the only way to observe the cross-boundary build:

  • TestInjectCC608AVC / HEVC — flips land on frames 30, 60, 90 of the two-unit stream, and the frame-60 flip
    (unit B's first cue, built in unit A's tail) carries unit B's segment number.
  • TestGenLiveSegmentCC608 — two real testpic_2s/V300 segments through genLiveSegment plus an encode
    round-trip: 00:01:21.000/SEG 40, 00:01:22.000/SEG 41, 00:01:23.000/SEG 41.
  • TestGenLiveSegmentCC608HEVC — same for bbb_hevc_ac3_8s at 24 fps (frames 24/48/72).
  • TestGenLiveSegmentCC608_2997fps — two 2.002 s segments at 30000/1001: 00:01:21.081, 00:01:22.082,
    00:01:23.083, so the boundary cue is frame-accurate where a fractional frame duration would otherwise drift.
  • TestGenLiveSegmentCC608NrTimeOffset — the caption clock follows the segment's media time, not
    segmentNr * segmentDuration: with startnr_5 segment 45 is the 80 s segment and reads
    00:01:21.000/SEG 45, and with availabilityStartTime an hour past the epoch the same media and numbers read
    01:01:21.000/SEG 40. Deriving the clock from meta.newNr * meta.newDur instead fails only this test, at
    00:01:31.000.
  • TestInjectCC608FirstCueUnbuilt — pins the mid-stream-join behaviour described above.
  • TestCC608UnitFramesBuildDoesNotFit — the new misfit error.
  • TestInjectCC608SelfContained / TestGenLiveSegmentCC608SelfContainedSegment — in -sc mode a single unit
    shows both of its cues (the direct contrast with TestInjectCC608FirstCueUnbuilt, where the same unit shows only
    its second), each flip landing inside the cue it names. The second test goes through genLiveSegment, so the
    CC608Config.SelfContained -> applyCC608 plumbing is covered.
  • TestCC608UnitFramesSelfContainedDoesNotFit — the self-contained misfit error.
  • TestCreateCC608Config / TestProcessURLCfg / TestCreateURLCC608 — the [-sc] grammar, including the
    Sardinian-tag cases, the URL -> config wiring, and the wizard emitting timecc608_CC1-eng-sc.

Related

The same scheduling change is wanted in Eyevinn/moqlivemock (Eyevinn/moqlivemock#118), which shares this
scheduler, and the logic could later move into go-608 itself (Eyevinn/go-608#55) so both consumers share one
implementation. It was surfaced by player-side caption work where a low-latency player cannot recover a caption
transmitted after the moment it should be displayed.

tobbee added 3 commits July 28, 2026 10:16
A pop-on caption needs a build written into non-displayed memory plus an EOC that flips it on screen, and both drain at one 608 pair per frame. BuildUnitCues starts the build at its own cue's first frame, so the flip landed 15-20 frames into the cue - 0.6-0.75s of a one-second cue - and the caption became visible well after the time it displays.

Place each cue's EOC on its cue's first frame and transmit the build over the preceding frames instead, so the flip coincides with the cue boundary and a caption is shown over exactly the interval its text names. This is driven directly off go-608's schedule.Scheduler, whose FIFO is gated by eligibility time, so pushing build-then-EOC keeps the byte stream ordered and lands the flip on the intended frame.

Captions now span unit boundaries: a fragment carries the build for the first cue of whatever follows it, which is why the next unit's segment number is passed down. A client starting or seeking mid-stream gets a leading EOC without its build and shows no caption for that first cue period, then is correct. Each cue is encoded with a fresh encoder so its build always fully describes its screen, which is what lets independently generated on-demand segments line up. The 608 data rate is unchanged at one pair per frame.
…ELOG

Move the explanation of where a cue's build and flip sit, and what that costs at a discontinuity, into the README next to the timecc608 description, and cut the CHANGELOG entry down to a summary pointing there.

Correct one claim while doing it: a client hitting a segment's leading EOC without its build was described as showing no caption for that cue period, which only holds for a fresh 608 decoder. One that keeps state across a seek flips whatever was last preloaded and can show a stale caption instead. Neither can be prevented server-side, since an ENM ahead of the EOC would erase the build about to be flipped.
The stale-caption case after a seek is fixed by the player resetting its 608 decoder state at a discontinuity, as it would any other decoder. The server has no lever here, so livesim2 keeps flipping each segment's first cue rather than skipping it.
@tobbee
tobbee force-pushed the fix/cc608-cue-boundary-flip branch from d0032da to 5d43eac Compare July 28, 2026 07:17
tobbee added 2 commits July 29, 2026 10:45
The caption clock must come from the segment's own media time, not from segmentNr * segmentDuration. startnr_ renumbers the segments and a non-zero availabilityStartTime shifts the wall clock without touching the numbers, so either option decouples the two. Add TestGenLiveSegmentCC608NrTimeOffset with a startnr_5 case (segment 45 is the 80 s segment, so the clock reads 00:01:2x) and an AST=3600 case (same media and numbers, clocks an hour later), both decoding real B-frame segments end to end. Computing the clock from meta.newNr * meta.newDur instead makes only the startnr_5 case fail, at 00:01:31.000.
Flipping each caption on its cue's first frame is frame-accurate but makes a caption span the segment boundary, which costs a client that starts, seeks or joins mid-stream its first cue period. Make that trade selectable per stream: timecc608_CC1-eng-sc keeps a cue's build and flip inside the cue's own frames, so every segment is independently decodable, at the price of the caption appearing ~0.5 s into the second its clock names (measured: flips at frames 15 and 45 for cue boundaries 0 and 30 at 30 fps). The default is unchanged.

The grammar is now <channel>-<lang>[-sc]. A trailing sc is only the flag when a language precedes it, so CC1-sc stays the Sardinian tag, CC1-sc-sc is that language self-contained, and CC1-eng-xx remains the language eng-xx; only a tag whose final subtag is literally sc cannot be expressed.

cc608UnitFrames takes a cc608FlipMode and keeps the fresh-encoder-per-cue rebuild in both modes rather than delegating to go-608's generate.BuildUnitCues + WithFlipAtCueStart: BuildUnitCues shares one encoder across a unit's cues, so cues after the first are diffs, and a receiver joining mid-unit would flip a diff and see one row of a two-row caption.

/urlgen documents the option and its trade-off; the field already accepted it since it validates through CreateCC608Config.
@tobbee tobbee changed the title fix(cc608): flip each caption on its cue's first frame fix(cc608): flip each caption on its cue's first frame, with a -sc opt-out Jul 29, 2026
@tobbee

tobbee commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #329, which generalises this. Rather than only moving the pop-on flip onto its cue's first frame, timecc608 now names the caption mode — <channel>-<lang>[-<mode>[-<modifier>]] with paint (the new default), pop, pop-sc, roll2 and roll3 — on top of go-608 v0.9.0, which generates all three CTA-608 modes per unit.

The default is paint-on, so segments are independently decodable out of the box; the flip-at-cue-start placement from this PR lives on as -pop, and the -sc opt-out as -pop-sc. #329 contains everything from this branch, so nothing here is lost. Closing in favour of it.

@tobbee tobbee closed this Aug 6, 2026
@tobbee
tobbee deleted the fix/cc608-cue-boundary-flip branch August 11, 2026 08:10
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.

CTA-608 captions are displayed ~0.6-0.76 s after the time they name

1 participant