Skip to content

Commit 3976ca6

Browse files
committed
feat(cc608): choose the timecc608 caption mode, defaulting to paint-on
timecc608 injected one pop-on caption per second, built and flipped inside the cue's own frames, so the caption became visible ~0.5 s into the second its clock named. go-608 v0.9.0 generates all three CTA-608 caption modes per unit, so the option now names one: <channel>-<lang>[-<mode>[-<modifier>]] with paint (the default), pop, pop-sc, roll2 and roll3. Paint-on is the default because it is self-contained by construction: each cue clears the screen and writes the caption straight onto it two characters per frame, so no cue's data leaves its own segment, every segment decodes standalone, and a client that starts, seeks or joins anywhere is correct from the first cue boundary it sees. The typing doubles as a liveness tell. Roll-up (roll2, roll3) types each line onto the base row of a window that scrolls the earlier lines up, reset at the start of every segment so the display owes nothing to the previous one; it is limited to 2 or 3 rows because the caption's own lines put its base row at row 3. Pop-on keeps both placements. With -pop each cue's EOC rides its cue's first frame and the build drains over the frames before it, so the caption is displayed over exactly the interval its text names; the cost is that a cue's build lives in the preceding segment, and a receiver joining mid-stream gets a leading EOC without it - blank on a fresh decoder, one cue of a stale caption on one that keeps 608 state, correct from the next cue on. That is a receiver-side matter: an ENM ahead of the EOC would erase the build about to be flipped. -pop-sc trades the frame accuracy back for a segment that stands alone. The scheduling is go-608's per-unit builders throughout. Their generate.Unit carries a unit's number, start time and frame count as independent facts, so a segment whose number does not match its media time - startnr_, a non-zero availabilityStartTime - still gets the right clock, and the next unit's start comes from the fragment's media duration in ticks rather than frames times frame duration, which a fractional frame rate would drift on. NumCues divides a segment down into whole periods, so a cue is never shorter than a second: a 1.92s segment gets one 1.92s cue instead of two of 0.96s. The language field is a three-letter ISO 639-2 code. It is used in one place, the SCTE 214-1 accessibility descriptor value (value="CC1=eng"), whose scheme is defined in those terms; it is not DASH's RFC 5646 @lang, which this option never sets, so en, en-US and zh-Hans would have gone straight into a non-conformant descriptor. A single-token language also makes the value's fields positional, so a mistyped mode or modifier is reported instead of being read as part of the language. The tests decode the injected cc_data back in presentation order - the order a conformant receiver reassembles it in - and assert the caption text and the frame it lands on: across two consecutive segments for the cross-segment pop-on case, for AVC and HEVC, at 30, 29.97 and 24 fps, through the low-latency chunked path, and per frame for paint-on's clear-and-type and roll-up's scroll history. Test configurations come from CreateCC608Config, so each test names its mode exactly as a request does.
1 parent 9a7bcc0 commit 3976ca6

12 files changed

Lines changed: 898 additions & 189 deletions

CHANGELOG.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Optional mode field on `timecc608`, whose value is now `<channel>-<lang>[-<mode>[-<modifier>]]`, choosing
13+
the CTA-608 caption mode: `paint` (the default), `pop`, `pop-sc`, `roll2` or `roll3`. Paint-on and roll-up
14+
type the caption out two characters per frame — paint-on onto a screen cleared at the start of each
15+
second, roll-up onto the base row of a 2- or 3-row window that scrolls the earlier lines up — and both
16+
keep every caption inside the segment that carries it, so segments stay independently decodable for a
17+
client that starts, seeks or joins mid-stream. Roll-up is limited to 2 or 3 rows because the caption's own
18+
lines put its base row at row 3. Generated by go-608 v0.9.0's `generate.BuildUnitPaintCues` and
19+
`BuildUnitRollUpCues`.
20+
1021
### Changed
1122

12-
- mp4ff dependency bumped to v0.55.0 and go-608 to v0.7.0
23+
- `timecc608` defaults to paint-on instead of pop-on, so by default no segment's captions depend on its
24+
neighbours. Pop-on remains available as `-pop`, with `-pop-sc` for the self-contained placement; a bare
25+
`-sc` (which used to ask for that) is rejected with a message pointing at `-pop-sc`.
26+
- The `timecc608` language must be a three-letter ISO 639-2 code (`eng`, `swe`), the form the SCTE 214-1
27+
CEA-608 accessibility descriptor value is defined in, and the only place this option's language is used.
28+
RFC 5646 tags that DASH's `@lang` accepts — `en`, `en-US`, `zh-Hans` — were previously allowed through
29+
into `value="CC1=..."` and are now rejected. With the language a single field, an unknown mode or
30+
modifier is reported instead of being read as part of it.
31+
- mp4ff dependency bumped to v0.55.0 and go-608 to v0.9.0
32+
- The CTA-608 cue scheduling for `timecc608` is back to go-608's per-unit builders, whose v0.8.0
33+
`generate.Unit` (number, start time and frame count as independent inputs) and `WithFlipAtCueStart`
34+
cover both pop-on flip placements livesim2 needs, so the local copy of that scheduler is gone. Same
35+
bytes on the wire.
36+
- A `timecc608` caption cue is never shorter than a second now that go-608 v0.9.0 divides a segment down
37+
into whole periods: a segment whose duration is not a multiple of a second, e.g. 1.92 s, gets one cue
38+
covering it instead of two shorter ones. Segments of 2 s and 2.002 s are unaffected.
39+
40+
### Fixed
41+
42+
- In-band CTA-608 captions in pop-on mode (`timecc608_CC1-eng-pop`) are now displayed over exactly the
43+
interval their text names, instead of appearing 0.6-0.75 s into it. Each cue's flip rides its cue's first
44+
frame, with the build sent over the preceding frames. Such captions consequently span segment boundaries,
45+
which costs a client that starts or seeks mid-stream its first cue period — see the
46+
[README](README.md). The new default mode, paint-on, has neither problem.
1347

1448
## [1.12.0] - 2026-07-23
1549

README.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,68 @@ There is a corresponding setting for `wvtt` (segmented WebVTT) subtitles using `
3333
For in-band closed captions, `/timecc608_CC1-eng` injects a CTA-608 (CEA-608) caption
3434
into the AVC/HEVC video itself, showing a ticking UTC clock and the segment number on
3535
channel CC1, and advertises it with a CEA-608 `Accessibility` descriptor. The value is
36-
`<channel>-<lang>` (only `CC1` is supported so far). It cannot be combined with encryption
37-
and is rejected for assets that already carry captions.
36+
`<channel>-<lang>[-<mode>[-<modifier>]]` (only `CC1` is supported so far). It cannot be
37+
combined with encryption and is rejected for assets that already carry captions.
38+
39+
`<lang>` is a three-letter ISO 639-2 code, since it ends up in the SCTE 214-1 descriptor
40+
value — `<Accessibility schemeIdUri="urn:scte:dash:cc:cea-608:2015" value="CC1=eng"/>`
41+
whose scheme is defined in those terms. That is a different language form from DASH's
42+
RFC 5646 `@lang` attribute, which this option leaves alone, so `eng` and `swe` are codes
43+
here while `en`, `en-US` and `zh-Hans` are rejected.
44+
45+
The optional mode field picks how a caption reaches the screen. All three write one 608
46+
byte pair per frame — that is the CTA-608 wire rate — so what differs is where those pairs
47+
go and what a receiver needs besides the segment in hand:
48+
49+
| mode | URL | caption appears | self-contained segments |
50+
|---|---|---|---|
51+
| paint-on | `/timecc608_CC1-eng` (default) | types on, two characters per frame | yes |
52+
| roll-up | `/timecc608_CC1-eng-roll3` | types onto a scrolling window | yes |
53+
| pop-on | `/timecc608_CC1-eng-pop` | whole, exactly on its second | no |
54+
| pop-on | `/timecc608_CC1-eng-pop-sc` | whole, ~0.5 s late | yes |
55+
56+
**Paint-on** is the default. Each second clears the screen and then writes the caption
57+
straight onto it, two characters at a time, so it takes ~0.5 s at 30 fps to arrive and
58+
stands complete for the rest of the second. Nothing a cue needs lives outside its own
59+
segment, so every segment decodes standalone: a client can start, seek or join anywhere
60+
and be correct from the first cue boundary it sees. The typing is also a useful liveness
61+
tell — a frozen caption is a stalled stream.
62+
63+
**Roll-up** (`-roll2`, `-roll3`) types each line onto the base row of a scrolling window
64+
of that many rows, the way live broadcast captioning works. The caption lines sit on rows
65+
2 and 3, which makes row 3 the base row and caps the window at 3 rows — a 4-row window
66+
would need a row 0. With two lines per second, `-roll2` keeps no history and `-roll3`
67+
keeps the previous second's bottom line. The window is reset at the start of each segment,
68+
so the display owes nothing to the previous segment either.
69+
70+
**Pop-on** (`-pop`) is the mode with a trade-off. A pop-on caption is two transmissions —
71+
a build written into the receiver's non-displayed memory, and an `EOC` that flips it on
72+
screen. The flip rides the first frame of its cue and the ~15-19 pair build is sent over
73+
the frames *before* it, so the caption is displayed over exactly the interval its text
74+
names. But that build has to live somewhere: for a segment's first cue it is in the
75+
**previous segment**, and each segment likewise carries the build for the first cue of the
76+
segment that follows it. Segments are still generated independently and on demand, since
77+
both sides derive that shared cue from the same wall-clock time and segment number.
78+
79+
The cost is that captions are not self-contained per segment. A client that starts, seeks,
80+
or joins mid-stream gets a segment's leading `EOC` without the build that belongs to it,
81+
and what it shows for that first cue period depends on its decoder: a fresh 608 decoder has
82+
nothing loaded and shows **no caption**, while one that keeps 608 state across the
83+
discontinuity flips whatever was last preloaded and can show **one cue of a stale
84+
caption**. Either way it corrects at the next cue boundary, typically within a second.
85+
86+
This is a receiver-side matter, not something the server can paper over: any pair sent
87+
ahead of the `EOC` to clear the state would erase the build that is about to be flipped.
88+
A player should reset its 608 decoder state on a seek or other discontinuity — which is
89+
what turns the stale case into the blank one — exactly as it resets any other decoder.
90+
91+
Appending `-sc` to pop-on (`/timecc608_CC1-eng-pop-sc`) switches that trade the other way:
92+
a cue's build and its flip both ride the cue's own frames, so every caption stays inside
93+
the segment that carries it. The cost is latency — the flip can only follow its own build,
94+
so each caption appears ~0.5 s into the second its clock names and remains up into the next
95+
one. Use `-pop` for frame-accurate timing, `-pop-sc` to test a player against pop-on
96+
captions that are decodable segment by segment. `-sc` applies to pop-on only; paint-on and
97+
roll-up are self-contained by construction.
3898

3999
The new `livesim2` software is written in Go instead of Python and designed to handle
40100
content in a more flexible and versatile way. It is intended to be very easy to install and deploy locally

cmd/livesim2/app/cc608.go

Lines changed: 141 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package app
77
import (
88
"fmt"
99
"regexp"
10+
"sort"
1011
"strings"
1112
)
1213

@@ -19,32 +20,160 @@ import (
1920
type CC608Config struct {
2021
// Channel is the CEA-608 channel. Only "CC1" is supported in the first milestone.
2122
Channel string `json:"Channel"`
22-
// Lang is the RFC-5646 language code used both for the caption content locale
23-
// and for the value of the MPD Accessibility descriptor.
23+
// Lang is the caption language as a three-letter ISO 639-2 code (eng, swe). It is
24+
// used for the CEA-608 Accessibility descriptor value, "<channel>=<lang>", whose
25+
// SCTE 214-1 scheme (urn:scte:dash:cc:cea-608:2015) is defined in terms of that
26+
// three-letter form — not DASH's RFC 5646 @lang, which this option never sets.
2427
Lang string `json:"Lang"`
28+
// Mode is the CTA-608 caption mode: paint-on (the default), pop-on or roll-up.
29+
// It decides how a caption reaches the screen, and with it how much a segment
30+
// depends on its neighbours — see the CC608Mode constants.
31+
Mode CC608Mode `json:"Mode,omitempty"`
32+
// RollUpRows is the roll-up window height in rows, 2 or 3, and is only set for
33+
// CC608RollUp. A cue writes two lines, so a 2-row window keeps no history and a
34+
// 3-row one keeps the previous cue's bottom line. The caption lines sit on rows
35+
// 2 and 3 (cc608Line1Row, cc608Line2Row), which makes row 3 the roll-up base row
36+
// and caps the window at 3: a 4-row window would need a row 0.
37+
RollUpRows int `json:"RollUpRows,omitempty"`
38+
// SelfContained keeps every caption inside the segment that carries it, and only
39+
// applies to CC608PopOn: a cue's pop-on build and its EOC flip both ride the cue's
40+
// own frames, so no segment depends on its neighbour. The flip then lands
41+
// ~build-pairs frames into the cue (0.6-0.75 s of a one-second cue), so the caption
42+
// lags the interval its text names. Unset, a pop-on caption flips on its cue's first
43+
// frame instead, which is frame-accurate but makes a caption span the segment
44+
// boundary. Paint-on and roll-up are self-contained by construction.
45+
SelfContained bool `json:"SelfContained,omitempty"`
2546
}
2647

27-
// cc608LangRegexp is a light RFC-5646 check: a 2-3 letter primary subtag followed by
28-
// optional hyphen-separated subtags (e.g. eng, swe, en-US, zh-Hans).
29-
var cc608LangRegexp = regexp.MustCompile(`^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$`)
48+
// CC608Mode is the CTA-608 caption mode used to put a caption on screen. The three
49+
// differ in what a receiver needs in order to render a segment, which is what makes the
50+
// choice interesting for a test stream.
51+
type CC608Mode string
52+
53+
const (
54+
// CC608PaintOn writes each cue straight onto the displayed screen, two characters
55+
// per frame, after clearing it. Every cue is self-contained in its own slice, so a
56+
// segment never depends on a neighbour and a client can start, seek or join
57+
// anywhere. The caption arrives progressively, complete only for the tail of its
58+
// cue. This is the default.
59+
CC608PaintOn CC608Mode = "paint"
60+
// CC608PopOn builds each cue in non-displayed memory and flips it on with an EOC.
61+
// The flip rides the cue's first frame, so the caption is displayed over exactly the
62+
// interval its clock names, at the cost of the build living in the previous segment
63+
// (see CC608Config.SelfContained for the self-contained variant).
64+
CC608PopOn CC608Mode = "pop"
65+
// CC608RollUp types each cue onto the base row of a scrolling window, so earlier
66+
// cues age upward. The window is reset at the start of every segment, which keeps
67+
// the segment self-contained in display as well as data.
68+
CC608RollUp CC608Mode = "roll"
69+
)
70+
71+
// cc608SelfContainedToken is the modifier that asks a pop-on stream to keep every
72+
// caption inside its own segment.
73+
const cc608SelfContainedToken = "sc"
74+
75+
// cc608Modes maps the mode field of a timecc608 value onto a mode and, for roll-up, its
76+
// window height. Roll-up is limited to 2 and 3 rows by the caption's own row placement
77+
// (see CC608Config.RollUpRows).
78+
var cc608Modes = map[string]struct {
79+
mode CC608Mode
80+
rows int
81+
}{
82+
"paint": {CC608PaintOn, 0},
83+
"pop": {CC608PopOn, 0},
84+
"roll2": {CC608RollUp, 2},
85+
"roll3": {CC608RollUp, 3},
86+
}
87+
88+
// cc608ModeList names the supported mode tokens for an error message. Sorted, so the
89+
// map stays the single source of truth: paint, pop, roll2, roll3.
90+
func cc608ModeList() string {
91+
tokens := make([]string, 0, len(cc608Modes))
92+
for t := range cc608Modes {
93+
tokens = append(tokens, t)
94+
}
95+
sort.Strings(tokens)
96+
return "known modes: " + strings.Join(tokens, ", ")
97+
}
98+
99+
// cc608LangRegexp matches a three-letter ISO 639-2 code, the language form the CEA-608
100+
// accessibility descriptor uses (see CC608Config.Lang). Not a check against the ISO 639-2
101+
// register, just the shape, which is what keeps the descriptor value well-formed.
102+
var cc608LangRegexp = regexp.MustCompile(`^[A-Za-z]{3}$`)
30103

31104
// CreateCC608Config parses the value of a "timecc608" URL option.
32105
//
33-
// Grammar: <channel>-<lang>, e.g. CC1-eng.
34-
// - channel: CEA-608 channel; only CC1 is supported in the first milestone.
35-
// - lang: RFC-5646 language code (caption locale + MPD Accessibility value).
106+
// Grammar: <channel>-<lang>[-<mode>[-<modifier>]], e.g. CC1-eng, CC1-eng-pop-sc or
107+
// CC1-eng-roll3. The fields are positional, so each one is exactly one hyphen-separated
108+
// token:
109+
// - channel: CEA-608 channel; only CC1 is supported in the first milestone.
110+
// - lang: three-letter ISO 639-2 code for the descriptor value (see
111+
// CC608Config.Lang). This is the SCTE 214-1 language form and not DASH's RFC 5646
112+
// @lang, so eng and swe are codes here while en, en-US and zh-Hans are not.
113+
// - mode: optional caption mode; paint (the default), pop, roll2 or roll3.
114+
// - modifier: optional; only "sc" after "pop", which keeps each pop-on caption inside
115+
// its own segment. Paint-on and roll-up are self-contained anyway.
116+
//
117+
// Because the language is a single token, an unknown mode or modifier is reported rather
118+
// than swallowed as part of it: "CC1-eng-roll4" and "CC1-eng-xx" are errors, not requests
119+
// for a language "eng-roll4". The one value worth a special word is a lone mode token,
120+
// "CC1-pop", where the language is missing rather than named "pop".
36121
func CreateCC608Config(val string) (*CC608Config, error) {
37-
channel, lang, ok := strings.Cut(val, "-")
122+
channel, rest, ok := strings.Cut(val, "-")
38123
if !ok {
39-
return nil, fmt.Errorf("timecc608 must be <channel>-<lang>, got %q", val)
124+
return nil, fmt.Errorf("timecc608 must be <channel>-<lang>[-<mode>[-<modifier>]], got %q", val)
40125
}
41126
if channel != "CC1" {
42127
return nil, fmt.Errorf("timecc608 channel %q not supported (only CC1)", channel)
43128
}
129+
fields := strings.Split(rest, "-")
130+
if len(fields) > 3 {
131+
return nil, fmt.Errorf("timecc608 %q has %d fields after the channel, at most 3 "+
132+
"(<lang>[-<mode>[-<modifier>]])", val, len(fields))
133+
}
134+
lang := fields[0]
135+
// A value that is nothing but a mode token has lost its language rather than named
136+
// one. Checked before the shape, since "pop" is itself three letters.
137+
if _, isMode := cc608Modes[lang]; isMode && len(fields) == 1 {
138+
return nil, fmt.Errorf("timecc608 %q: the language comes before the mode, e.g. %s-eng-%s",
139+
val, channel, lang)
140+
}
44141
if !cc608LangRegexp.MatchString(lang) {
45-
return nil, fmt.Errorf("timecc608 language %q is not a valid RFC-5646 code", lang)
142+
return nil, fmt.Errorf("timecc608 language %q is not a three-letter ISO 639-2 code", lang)
143+
}
144+
mode, rows := CC608PaintOn, 0
145+
if len(fields) >= 2 {
146+
m, isMode := cc608Modes[fields[1]]
147+
if !isMode {
148+
if fields[1] == cc608SelfContainedToken {
149+
// A bare "sc" was the self-contained field before modes existed, so name the
150+
// mode it now modifies rather than just listing the modes.
151+
return nil, fmt.Errorf("timecc608 %q: %q must follow a mode, e.g. %s-%s-pop-%s",
152+
val, cc608SelfContainedToken, channel, lang, cc608SelfContainedToken)
153+
}
154+
return nil, fmt.Errorf("timecc608 mode %q not supported (%s)", fields[1], cc608ModeList())
155+
}
156+
mode, rows = m.mode, m.rows
157+
}
158+
selfContained := false
159+
if len(fields) == 3 {
160+
if fields[2] != cc608SelfContainedToken {
161+
return nil, fmt.Errorf("timecc608 modifier %q not supported (only %q)",
162+
fields[2], cc608SelfContainedToken)
163+
}
164+
if mode != CC608PopOn {
165+
return nil, fmt.Errorf("timecc608 modifier %q applies to mode pop only; %s is self-contained already",
166+
cc608SelfContainedToken, mode)
167+
}
168+
selfContained = true
46169
}
47-
return &CC608Config{Channel: channel, Lang: lang}, nil
170+
return &CC608Config{
171+
Channel: channel,
172+
Lang: lang,
173+
Mode: mode,
174+
RollUpRows: rows,
175+
SelfContained: selfContained,
176+
}, nil
48177
}
49178

50179
// ParseCC608Config parses a "timecc608" option value, accumulating any error.

0 commit comments

Comments
 (0)