@@ -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.
283337func 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