Skip to content

Commit 613c05d

Browse files
committed
feat(cc608): add timecc608 -sc for self-contained segment captions
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.
1 parent e2cc181 commit 613c05d

10 files changed

Lines changed: 247 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Optional `-sc` field on `timecc608` (e.g. `timecc608_CC1-eng-sc`) keeps every CTA-608 caption inside the
13+
segment that carries it, so segments stay independently decodable for a client that starts, seeks or joins
14+
mid-stream. In exchange each caption appears ~0.5 s into the second its clock names, which is the behaviour
15+
from before the flip-on-cue-start fix below.
16+
1017
### Changed
1118

1219
- mp4ff dependency bumped to v0.55.0 and go-608 to v0.7.0

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ 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
36+
`<channel>-<lang>[-sc]` (only `CC1` is supported so far). It cannot be combined with encryption
3737
and is rejected for assets that already carry captions.
3838

3939
Each caption is displayed over exactly the interval its text names. A pop-on caption is
@@ -58,6 +58,14 @@ ahead of the `EOC` to clear the state would erase the build that is about to be
5858
A player should reset its 608 decoder state on a seek or other discontinuity — which is
5959
what turns the stale case into the blank one — exactly as it resets any other decoder.
6060

61+
Appending `-sc` (`/timecc608_CC1-eng-sc`) switches this trade the other way: a cue's build
62+
and its flip both ride the cue's own frames, so every caption stays inside the segment that
63+
carries it and no segment depends on its neighbour. A client can then start, seek or join
64+
anywhere and see a complete caption immediately. The cost is latency — the flip can only
65+
follow its own build, so each caption appears ~0.5 s into the second its clock names and
66+
remains up into the next one. Use the default for frame-accurate timing, `-sc` to test a
67+
player against captions that are decodable segment by segment.
68+
6169
The new `livesim2` software is written in Go instead of Python and designed to handle
6270
content in a more flexible and versatile way. It is intended to be very easy to install and deploy locally
6371
since it is compiled into a single binary that serves the content via a built-in

cmd/livesim2/app/cc608.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,61 @@ type CC608Config struct {
2222
// Lang is the RFC-5646 language code used both for the caption content locale
2323
// and for the value of the MPD Accessibility descriptor.
2424
Lang string `json:"Lang"`
25+
// SelfContained keeps every caption inside the segment that carries it: a cue's
26+
// pop-on build and its EOC flip both ride the cue's own frames, so no segment
27+
// depends on its neighbour. The flip then lands ~build-pairs frames into the cue
28+
// (0.6-0.75 s of a one-second cue), so the caption lags the interval its text
29+
// names. The default (false) flips each caption on its cue's first frame instead,
30+
// which is frame-accurate but makes a caption span the segment boundary.
31+
SelfContained bool `json:"SelfContained,omitempty"`
2532
}
2633

34+
// cc608SelfContainedToken is the optional trailing field of the timecc608 value that
35+
// asks for self-contained segments.
36+
const cc608SelfContainedToken = "sc"
37+
2738
// cc608LangRegexp is a light RFC-5646 check: a 2-3 letter primary subtag followed by
2839
// optional hyphen-separated subtags (e.g. eng, swe, en-US, zh-Hans).
2940
var cc608LangRegexp = regexp.MustCompile(`^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$`)
3041

3142
// CreateCC608Config parses the value of a "timecc608" URL option.
3243
//
33-
// Grammar: <channel>-<lang>, e.g. CC1-eng.
44+
// Grammar: <channel>-<lang>[-sc], e.g. CC1-eng or CC1-eng-sc.
3445
// - channel: CEA-608 channel; only CC1 is supported in the first milestone.
3546
// - lang: RFC-5646 language code (caption locale + MPD Accessibility value).
47+
// - sc: optional; keep captions self-contained per segment (see
48+
// CC608Config.SelfContained). Omitted, captions flip on their cue's first frame.
49+
//
50+
// Since a language tag may itself carry hyphenated subtags (en-US, zh-Hans), the "sc"
51+
// field is only recognized as such when the value has three or more fields; the lang is
52+
// everything between the channel and it. "CC1-sc" is therefore the language "sc"
53+
// (Sardinian), and "CC1-sc-sc" is that language with self-contained segments. The one
54+
// tag this cannot express is a language whose final subtag is literally "sc".
3655
func CreateCC608Config(val string) (*CC608Config, error) {
37-
channel, lang, ok := strings.Cut(val, "-")
56+
channel, rest, ok := strings.Cut(val, "-")
3857
if !ok {
39-
return nil, fmt.Errorf("timecc608 must be <channel>-<lang>, got %q", val)
58+
return nil, fmt.Errorf("timecc608 must be <channel>-<lang>[-sc], got %q", val)
4059
}
4160
if channel != "CC1" {
4261
return nil, fmt.Errorf("timecc608 channel %q not supported (only CC1)", channel)
4362
}
63+
lang := rest
64+
selfContained := false
65+
if pre, last, hasMore := cutLast(rest, "-"); hasMore && last == cc608SelfContainedToken {
66+
lang, selfContained = pre, true
67+
}
4468
if !cc608LangRegexp.MatchString(lang) {
4569
return nil, fmt.Errorf("timecc608 language %q is not a valid RFC-5646 code", lang)
4670
}
47-
return &CC608Config{Channel: channel, Lang: lang}, nil
71+
return &CC608Config{Channel: channel, Lang: lang, SelfContained: selfContained}, nil
72+
}
73+
74+
// cutLast splits s around the last instance of sep, like strings.Cut around the first.
75+
func cutLast(s, sep string) (before, after string, found bool) {
76+
if i := strings.LastIndex(s, sep); i >= 0 {
77+
return s[:i], s[i+len(sep):], true
78+
}
79+
return s, "", false
4880
}
4981

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

cmd/livesim2/app/cc608_inject.go

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,36 @@ func cc608PairCount(toks []cta608.Token) int {
8585
})) / 2
8686
}
8787

88+
// cc608FlipMode selects where a cue's pop-on flip lands relative to the cue it names.
89+
type cc608FlipMode int
90+
91+
const (
92+
// cc608FlipAtCueStart puts each EOC on its cue's first frame, transmitting the
93+
// build over the preceding frames. Frame-accurate, but captions span unit
94+
// boundaries. This is the default.
95+
cc608FlipAtCueStart cc608FlipMode = iota
96+
// cc608SelfContained keeps a cue's build and flip inside the cue's own frames, so
97+
// no unit depends on its neighbour. The flip lands ~build-pairs frames into the
98+
// cue, i.e. the caption lags the interval its text names.
99+
cc608SelfContained
100+
)
101+
88102
// cc608UnitFrames builds the per-frame CTA-608 schedule for one unit (one fragment)
89103
// of nFrames frames starting at unitStartMS.
90104
//
91105
// A pop-on caption occupies two transmissions: a build (RCL + ENM + rows) written
92106
// into non-displayed memory, and an EOC that flips it on screen. Both drain at one
93-
// 608 pair per frame, so where the build is placed decides when the caption
94-
// appears. go-608's generate.BuildUnitCues starts the build at its cue's first
95-
// frame, which puts the flip ~pairs frames *into* the cue — ~0.5-0.75s of a
96-
// one-second cue — so the caption became visible well after the time it displays.
107+
// 608 pair per frame, so where the build is placed decides when the caption appears.
108+
//
109+
// With mode cc608SelfContained, both ride the cue's own frames: the build drains from
110+
// the cue's first frame and the flip follows it, which puts the flip ~pairs frames
111+
// *into* the cue — 0.6-0.75s of a one-second cue — so the caption is visible well
112+
// after the time its text names. Nothing crosses a unit boundary, so every segment
113+
// decodes standalone and a client that starts, seeks or joins anywhere sees a correct
114+
// (if late) caption. nextContent is unused in this mode.
97115
//
98-
// Here each cue's EOC rides its cue's first frame and its build drains over the
116+
// With the default cc608FlipAtCueStart, each cue's EOC rides its cue's first frame
117+
// and its build drains over the
99118
// frames immediately before it, so the flip coincides with the cue boundary and the
100119
// caption is shown exactly over the interval its text names. The consequence is that
101120
// a cue's build lives in the preceding cue's frames: the first cue's build belongs to
@@ -110,12 +129,15 @@ func cc608PairCount(toks []cta608.Token) int {
110129
// case into the blank one — and not something the server can paper over, since an ENM
111130
// ahead of the EOC would erase the build about to be flipped.
112131
//
113-
// Each cue is encoded with a fresh cta608.Encoder so its build is always a complete
114-
// rebuild. That keeps every EOC paired with a build that fully describes its screen,
115-
// which is what makes independently generated, on-demand units line up: the build in
116-
// unit N's tail and the flip at unit N+1's first frame are produced by separate
117-
// calls and must agree without sharing encoder state.
118-
func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextContent generate.CueContentFunc) ([]schedule.Frame, error) {
132+
// In both modes each cue is encoded with a fresh cta608.Encoder so its build is always
133+
// a complete rebuild rather than a diff against the previous cue. That keeps every EOC
134+
// paired with a build that fully describes its screen, which is what makes
135+
// independently generated, on-demand units line up: the build in unit N's tail and the
136+
// flip at unit N+1's first frame are produced by separate calls and must agree without
137+
// sharing encoder state. It is also what lets a receiver joining mid-unit get a whole
138+
// caption rather than one row of one.
139+
func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextContent generate.CueContentFunc,
140+
mode cc608FlipMode) ([]schedule.Frame, error) {
119141
if nFrames <= 0 {
120142
return nil, fmt.Errorf("cc608: nFrames must be > 0, got %d", nFrames)
121143
}
@@ -130,6 +152,36 @@ func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextC
130152
// boundary(k) is the first unit-relative frame of cue k; boundary(n) == nFrames.
131153
boundary := func(k int) int { return int(math.Round(float64(k) * float64(nFrames) / float64(n))) }
132154

155+
// cueTokens encodes cue k from a clean encoder, split into the build and the EOC
156+
// that flips it. Pushes drain in push order (the scheduler is a FIFO gated by
157+
// eligibility time), so a build pushed before its EOC keeps the byte stream ordered.
158+
cueTokens := func(cue generate.UnitCue) (build, eoc []cta608.Token) {
159+
var enc cta608.Encoder
160+
return cc608SplitEOC(enc.Apply(cta608.CaptionBlock{Lines: cue.Lines, Mode: cta608.PopOn}))
161+
}
162+
163+
sched := schedule.NewScheduler(fps, schedule.WithDoubling(cta608.DoublingOff))
164+
if mode == cc608SelfContained {
165+
for k := 0; k < n; k++ {
166+
start, end := boundary(k), boundary(k+1)
167+
build, eoc := cueTokens(content(k, wallAt(start)))
168+
// Build and EOC are both eligible at the cue's first frame and drain one pair
169+
// per frame, so the flip lands at start+pairs. Require it to leave at least
170+
// one frame of display before the next cue takes the screen.
171+
if pairs := cc608PairCount(build) + cc608PairCount(eoc); start+pairs >= end {
172+
return nil, fmt.Errorf("cc608: cue %d needs %d frames to build and flip but its slice is only "+
173+
"%d frames at %g fps; shorten the lines or lower the update rate", k, pairs, end-start, fps)
174+
}
175+
if len(build) > 0 {
176+
sched.Push(schedule.TimedTokens{TimeMS: wallAt(start), Field: 1, Tokens: build})
177+
}
178+
if len(eoc) > 0 {
179+
sched.Push(schedule.TimedTokens{TimeMS: wallAt(start), Field: 1, Tokens: eoc})
180+
}
181+
}
182+
return cc608CollectFrames(sched, nFrames, wallAt), nil
183+
}
184+
133185
// Cue k flips at boundary(k). The extra entry at nFrames is the next unit's first
134186
// cue: its build drains this unit's tail, its EOC belongs to the next unit.
135187
type flip struct {
@@ -142,11 +194,9 @@ func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextC
142194
}
143195
flips = append(flips, flip{nFrames, nextContent(0, wallAt(nFrames))})
144196

145-
sched := schedule.NewScheduler(fps, schedule.WithDoubling(cta608.DoublingOff))
146197
prevFlip := -1 // last frame already claimed by a flip
147198
for i, f := range flips {
148-
var enc cta608.Encoder
149-
build, eoc := cc608SplitEOC(enc.Apply(cta608.CaptionBlock{Lines: f.cue.Lines, Mode: cta608.PopOn}))
199+
build, eoc := cueTokens(f.cue)
150200
buildStart := f.frame - cc608PairCount(build)
151201
// The first cue flips at frame 0, so its build was transmitted by the previous
152202
// unit and there is nothing to place here. Every other build must fit between
@@ -158,22 +208,23 @@ func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextC
158208
return nil, fmt.Errorf("cc608: cue flipping at frame %d needs %d frames of build but only %d are free "+
159209
"at %g fps; shorten the lines or lower the update rate", f.frame, f.frame-buildStart, free, fps)
160210
}
161-
// Pushes drain in push order (the scheduler is a FIFO gated by eligibility
162-
// time), so pushing the build before its EOC keeps the byte stream ordered
163-
// and lands the flip on f.frame exactly.
164211
sched.Push(schedule.TimedTokens{TimeMS: wallAt(buildStart), Field: 1, Tokens: build})
165212
}
166213
if f.frame < nFrames && len(eoc) > 0 {
167214
sched.Push(schedule.TimedTokens{TimeMS: wallAt(f.frame), Field: 1, Tokens: eoc})
168215
}
169216
prevFlip = f.frame
170217
}
218+
return cc608CollectFrames(sched, nFrames, wallAt), nil
219+
}
171220

221+
// cc608CollectFrames drains the scheduler into one entry per unit frame.
222+
func cc608CollectFrames(sched *schedule.Scheduler, nFrames int, wallAt func(int) int64) []schedule.Frame {
172223
frames := make([]schedule.Frame, nFrames)
173224
for i := range frames {
174225
frames[i] = sched.Frame(wallAt(i))
175226
}
176-
return frames, nil
227+
return frames
177228
}
178229

179230
// injectCC608 splices in-band CTA-608 caption SEI into a unit's video samples in
@@ -182,21 +233,23 @@ func cc608UnitFrames(fps float64, nFrames int, unitStartMS int64, content, nextC
182233
// SEI NALU before the first VCL NALU of each sample, updating Data and Size. samples
183234
// are the video track's FullSamples in decode order; fps and unitStartMS give the
184235
// caption timing; segNr is this unit's segment number and nextSegNr the segment
185-
// number of the unit that follows (the same segment for a non-final fragment).
236+
// number of the unit that follows (the same segment for a non-final fragment); mode
237+
// selects where each cue flips (nextSegNr is unused for cc608SelfContained).
186238
//
187239
// The schedule is presentation-ordered but samples arrive in decode order. Since
188240
// receivers reassemble cc_data by presentation time (PTS) — dash.js, hls.js and
189241
// Shaka all sort caption pairs by PTS — the k-th caption frame must ride the k-th
190242
// sample in *presentation* order. With B-frames (decode order != presentation order)
191243
// a naive frames[i]->samples[i] mapping permutes the CEA-608 byte stream and garbles
192244
// the caption.
193-
func injectCC608(samples []mp4.FullSample, fps float64, unitStartMS int64, segNr, nextSegNr uint32, codec carriage.Codec) error {
245+
func injectCC608(samples []mp4.FullSample, fps float64, unitStartMS int64, segNr, nextSegNr uint32,
246+
codec carriage.Codec, mode cc608FlipMode) error {
194247
if len(samples) == 0 {
195248
return nil
196249
}
197250
// cc608UnitFrames validates the frame rate and returns an error (never panics)
198251
// if it is out of the CEA-608 range.
199-
frames, err := cc608UnitFrames(fps, len(samples), unitStartMS, cc608CueContent(segNr), cc608CueContent(nextSegNr))
252+
frames, err := cc608UnitFrames(fps, len(samples), unitStartMS, cc608CueContent(segNr), cc608CueContent(nextSegNr), mode)
200253
if err != nil {
201254
return fmt.Errorf("cc608 build cues: %w", err)
202255
}
@@ -275,11 +328,12 @@ func isVCLNalu(nalu []byte, codec carriage.Codec) bool {
275328
// change, the moof size is unchanged and trun.DataOffset / mdat.StartPos stay
276329
// valid (mirroring the tfdt-shift bookkeeping in genLiveSegment).
277330
//
278-
// Each fragment is one caption unit. Because a cue's build is transmitted ahead of
279-
// its flip (see cc608UnitFrames), a fragment also carries the build for the first
280-
// cue of whatever follows it: the next fragment of this segment, or — for the last
331+
// Each fragment is one caption unit. In the default mode a cue's build is transmitted
332+
// ahead of its flip (see cc608UnitFrames), so a fragment also carries the build for the
333+
// first cue of whatever follows it: the next fragment of this segment, or — for the last
281334
// fragment — the first cue of the next segment, which is why the next unit's segment
282-
// number is passed down.
335+
// number is passed down. With timecc608's "-sc" (CC608Config.SelfContained) each unit
336+
// keeps its captions to itself and the next number goes unused.
283337
func applyCC608(seg *mp4.MediaSegment, meta segMeta, cfg *ResponseConfig) error {
284338
rep := meta.rep
285339
codec, ok := cc608CodecFor(rep.Codecs)
@@ -289,6 +343,10 @@ func applyCC608(seg *mp4.MediaSegment, meta segMeta, cfg *ResponseConfig) error
289343
if rep.initSeg == nil || rep.initSeg.Moov == nil || rep.initSeg.Moov.Mvex == nil {
290344
return fmt.Errorf("cc608: missing init/trex for representation %q", rep.ID)
291345
}
346+
mode := cc608FlipAtCueStart
347+
if cfg.CC608 != nil && cfg.CC608.SelfContained {
348+
mode = cc608SelfContained
349+
}
292350
trex := rep.initSeg.Moov.Mvex.Trex
293351
for i, frag := range seg.Fragments {
294352
samples, err := frag.GetFullSamples(trex)
@@ -310,7 +368,7 @@ func applyCC608(seg *mp4.MediaSegment, meta segMeta, cfg *ResponseConfig) error
310368
if i == len(seg.Fragments)-1 {
311369
nextSegNr = meta.newNr + 1
312370
}
313-
if err := injectCC608(samples, fps, unitStartMS, meta.newNr, nextSegNr, codec); err != nil {
371+
if err := injectCC608(samples, fps, unitStartMS, meta.newNr, nextSegNr, codec, mode); err != nil {
314372
return err
315373
}
316374
if err := writeBackCC608Samples(frag, samples); err != nil {

0 commit comments

Comments
 (0)