Inject subtitles into an mp4 (WebVTT/SRT are compiled; SCC rides byte-exact):

August 4, 2026 · View on GitHub

go-608 — CTA-608 captions in Go

Go Coverage Status golangci-lint Go Reference license

A pure-Go library for CTA-608 / CEA-608 captions: encode + decode, cc_data carriage per ATSC A/53 — SEI for AVC & HEVC, metadata_itu_t_t35 OBU for AV1 — wall-clock caption generation, and timed-text (SCC / WebVTT / SRT) I/O.

The full design lives in SPEC.md, with per-decision rationale in docs/design/.

Why "608" and "cta608"

The modern standard is CTA-608 (the org renamed from CEA to CTA), so the core package is cta608. The legacy "CEA-608" spelling persists in prior art, and used to persist in the mp4ff dependency's API; as of mp4ff v0.55.0 that API is ParseCTA608 / IsCTA608, so go-608 uses the modern spelling throughout.

Packages

go-608 is a set of cooperating packages. The module has no importable root package (the last path element go-608 is not a valid Go identifier); import the packages directly.

PackageResponsibility
cta608Pure core: Token stream, Screen, Serialize/Parse, Decoder/Encoder, CaptionBlock. A dependency-free leaf.
carriagecc_data / T.35 carriage: SEI + NAL for AVC & HEVC, metadata OBU for AV1. The only mp4ff importer.
scheduleTimed tokens → per-frame {Field1, Field2, CCCount}. The shared timing layer.
generateWall-clock caption Generator (per frame) and per-unit builders (per segment / MoQ group), in pop-on, paint-on or roll-up.
sccScenarist SCC read/write — byte-exact, true SMPTE drop-frame.
cueShared TimedCue intermediate + the 608↔cue mapping + a plugin seam.
webvttWebVTT serializer over the cue model.
srtSRT serializer over the cue model.

Dependency layering

cta608 (pure, leaf) ─┬─ scc
                     ├─ cue ─┬─ webvtt
                     │       └─ srt
                     ├─ carriage (+ mp4ff)   ← the only mp4ff importer
                     └─ schedule ── generate
cmd/ (go608-extract|inject|clock|info) wire the above; internal/ holds version + cmd glue.

The cta608 wire boundary

The core spine is a wire-faithful token stream. Serialize turns tokens into odd-parity cc_data byte pairs and Parse turns them back, so a []Token round-trips bytes exactly.

The token sum type (closed under the Token interface):

TokenMeaning
Charsa run of characters (standard, special, and extended glyphs)
PACPreamble Address Code: row + base pen, or row + indent
MidRowmid-row style change (color/underline/italics)
TabOffsetshift the cursor 1–3 columns
BackgroundAttrbackground color / transparent bg / black-foreground
SetModepop-on (RCL), roll-up (RU2/3/4), or paint-on (RDC)
Commandmisc control: EOC, EDM, ENM, CR, BS, DER, TR, RTD, FON, …

Serialize/Parse own all byte concerns so the token model stays logical: odd parity, control-code doubling (on for field 1, off for field 2 by default, overridable), two-characters-per-pair packing, null-pair frame alignment of two-byte control codes, and extended-character backspace-and-replace. ParseOptions chooses validate-vs-strip parity; SerializeOptions selects the field, channel, and doubling policy.

tokens := []cta608.Token{
    cta608.SetMode{Mode: cta608.PopOn},
    cta608.PAC{Row: 15, Indent: cta608.NoIndent, Pen: cta608.Pen{Color: cta608.White}},
    cta608.Chars{Text: "HELLO"},
    cta608.Command{Op: cta608.EOC},
}
data := cta608.Serialize(tokens, cta608.SerializeOptions{}) // field 1, doubling on
back, _ := cta608.Parse(data, cta608.ParseOptions{})        // back == tokens

DemuxField/MuxField split and join the two in-field data channels by the control byte's high nibble. Screen/Row/Run/Pen are the sparse, derived display value types (Pen is a comparable value). A runnable round-trip lives in examples/. The stateful Decoder (tokens → Screen) lands in a later ticket.

Encoding: the Encoder and CaptionBlock

Encoder is the single per-channel diff engine (SPEC §2). It holds the currently displayed Screen and turns a target Screen into the []Token that transforms one into the other. All mode-specific token generation lives in one place:

  • pop-on — build into non-displayed memory and flip with EOC (RCL, ENM, the rows, EOC); an unchanged target emits nothing, clearing emits EDM.
  • roll-up — enter with RU2/3/4, append to the base row (a minimal delta: extending the bottom line emits only the new characters), and scroll with CR.
  • paint-on — enter with RDC and write changed rows directly to the display.

The diff bottoms out at the character-run within a row, so incremental changes stay small. The zero value is a valid pop-on Encoder; SetMode switches modes.

CaptionBlock is friendly authoring on top of Screen: Lines placed by an Anchor (top/bottom) with a per-line Align (left/center/right). Screen() compiles it to a target Screen, and the Encoder lowers each run's absolute column to a PAC indent + Tab Offset — compensating one column for the mid-row cell of a centered colored line (SPEC §7), so PAC(indent, white)TabMidRow(color) lands the text on its intended column.

block := cta608.CaptionBlock{
    Mode:   cta608.PopOn,
    Anchor: cta608.AnchorBottom,
    Lines: []cta608.Line{
        {Align: cta608.AlignCenter, Runs: []cta608.Run{{Text: "HELLO", Pen: cta608.Pen{Color: cta608.White}}}},
        {Align: cta608.AlignCenter, Runs: []cta608.Run{{Text: "WORLD", Pen: cta608.Pen{Color: cta608.Yellow}}}},
    },
}
var enc cta608.Encoder            // zero value: pop-on, empty display
tokens := enc.Apply(block)        // target Screen -> RCL/ENM … EOC token stream
data := cta608.Serialize(tokens, cta608.SerializeOptions{}) // to cc_data byte pairs

Power users skip CaptionBlock and hand Encoder.SetScreen a Screen they build directly. A runnable authoring snippet lives in examples/.

Decoding: the Decoder

Decoder is the inverse of Encoder — the stateful, per-channel interpreter that turns a token or byte stream into the displayed Screen. Feed parses cc_data byte pairs and interprets them; Push interprets an already-parsed []Token; Screen() returns the displayed rows.

var dec cta608.Decoder                 // zero value: pop-on, empty display
if err := dec.Feed(data); err != nil { // data == cc_data byte pairs (one channel)
    // handle parity error
}
screen := dec.Screen()                 // the sparse rendered rows

It models 608's double buffer with an internal displayed / non-displayed grid (pop-on writes to non-displayed and EOC promotes it; EDM clears the display), scrolls the roll-up window on CR, and writes paint-on rows straight to the display. Changed() reports whether the displayed Screen changed since the previous call — the signal WebVTT/SRT cue segmentation pivots on — and Mode() reports the current caption mode, which that segmentation also needs (see Timed-text cues). XDS is dropped by Parse and text mode (TR/RTD) is recognized but not rendered (SPEC §1.3). A runnable decode snippet lives in examples/.

Feeding is incremental. Successive Feed calls form one continuous stream, so driving the decoder one byte pair per video frame — which is what preserves per-frame timing — decodes identically to feeding the whole buffer. Two 608 constructs straddle a pair boundary and depend on that: a doubled control code (collapsed to one logical token, so a doubled roll-up CR scrolls once rather than twice) and an extended character with its preceding fallback, where the backspace-and-replace has to reach a character already displayed.

Carriage (cc_data / SEI)

The carriage package is the only mp4ff importer and the seam between 608 byte pairs and the elementary stream. It is pure and timing-free — the caller supplies ccCount (from schedule); carriage never imports the cta608 core.

Encode — build one frame's SEI NAL unit from the per-field byte pairs:

// field1/field2 are each 0 or 2 bytes; ccCount comes from the frame rate (SPEC §5.3).
nalu := carriage.FrameSEINALU(field1, field2, ccCount, carriage.CodecAVC)
// nalu is a BARE NAL unit. SpliceSEIBeforeVCL puts it into a sample — adding the
// 4-byte length prefix — ahead of the picture data (into a per-emission copy,
// before CENC).
data, err := carriage.SpliceSEIBeforeVCL(sample.Data, nalu, carriage.CodecAVC)

FrameSEINALU is BuildCCData (assemble the cc_data() per CTA-708-E §4.3: 608 constructs first, then DTVCC padding to ccCount) → SEIMessage (wrap as a user_data_registered_itu_t_t35 / GA94 SEI message) → NALU (serialize via mp4ff and prepend the codec NAL header — AVC 0x06 or HEVC prefix-SEI 39). The three "nothing here" encodings — an empty field pair, DTVCC padding, and the 0x80 0x80 608 null pair — are kept distinct.

SEIMessage returns an mp4ff sei.SEIMessage and takes no codec — the payload is codec-identical for AVC and HEVC. Use NALU to place the 608 message in one NAL unit together with other SEI messages (e.g. pic_timing); the codec is only needed there, for the NAL header:

msg := carriage.SEIMessage(carriage.BuildCCData(field1, field2, ccCount))
nalu := carriage.NALU(carriage.CodecAVC, msg, otherSEIMessage) // one NAL, N messages

Samples — the sample-level splice, identical work for every consumer, so it lives here rather than in each of them:

// One caption SEI into one mp4 sample, ahead of the picture data.
data, err := carriage.SpliceSEIBeforeVCL(s.Data, nalu, codec)
if err != nil {
    return err
}
s.Data, s.Size = data, uint32(len(data)) // the sample grew

// The split/join either side of it, plus the coded-slice predicate.
nalus, err := carriage.SampleNALUs(s.Data)  // 4-byte-length-prefixed → bare NALs
sample := carriage.PrefixNALUs(nalus...)    // and back
isPicture := carriage.IsVCL(nalus[0], codec)

A sample with no VCL NAL unit has no picture for the SEI to precede; the SEI is appended at the end, leaving the existing NAL order untouched. Nothing is dropped.

Decode — recover the field byte-pair streams from a sample's NAL units:

field1, field2, err := carriage.FieldPairs(sampleNALUs, carriage.CodecAVC)

FieldPairs wraps mp4ff's sei.ParseCTA608; the recovered pairs feed the cta608 core Decoder. See examples/ for a runnable round-trip and testdata/ for a fragmented-mp4 fixture.

AV1 (av01)

AV1 carries the same cc_data()BuildCCData is reused unchanged — under the same T.35/GA94 header. Only the envelope and the splice differ: a metadata_itu_t_t35 OBU instead of an SEI NAL unit, with no emulation prevention.

The AV1 functions run parallel to the SEI ones rather than extending them, and none takes a Codec:

// Encode: one frame's caption OBU, then into the sample.
obu := carriage.FrameMetadataOBU(field1, field2, ccCount)   // mirrors FrameSEINALU
data, err := carriage.SpliceOBUBeforeFrame(sample.Data, obu) // mirrors SpliceSEIBeforeVCL
s.Data, s.Size = data, uint32(len(data))

// Envelope level, when the cc_data() is built elsewhere.
obu = carriage.MetadataOBU(carriage.BuildCCData(field1, field2, ccCount))

// Decode: takes the raw sample, not a pre-split OBU list.
field1, field2, err := carriage.OBUFieldPairs(sample.Data)

carriage.Codec deliberately stays two-valued: it names NAL framing, which AV1 does not have. A consumer handling all three codecs therefore needs its own three-value discriminator — the point being that it will fail to compile rather than compile into a switch that quietly captions nothing for av01.

The OBU is placed after any sequence header and immediately before the first OBU_FRAME / OBU_FRAME_HEADER. Anchoring on the frame OBU rather than on a position from the start of the sample makes the rule mean the same thing in mp4 (where the muxer strips temporal delimiters) and in IVF (where it does not). Unlike SpliceSEIBeforeVCL there is no no-anchor fallback: every temporal unit must output a frame, so a sample without one is malformed and is reported as an error.

Assignment is one caption OBU per sample, in sample order — one sample is one temporal unit, and a temporal unit shows exactly one frame. Several frame OBUs in a sample (hidden reference frames) are not an ambiguity. This rests on OperatingPointIdc == 0: scalable AV1 is not supported, because the spec then allows one shown frame per layer and "the caption for this sample" stops naming a single picture. The go-608 tools reject a scalable av01 track rather than guess.

Scheduling (timed tokens → frames)

The schedule package is the shared timing layer between the logical token stream and the per-frame carriage payload. It is format-agnostic and carriage-free — it imports only cta608 — so both the wall-clock generater and the subtitle-compile path drive the same scheduler.

A Scheduler holds a FIFO byte-pair queue per NTSC field. Push serializes a wall-time-tagged batch of token transitions with cta608.Serialize and enqueues the resulting 2-byte pairs; Frame(frameWallMS) drains at most one pair per field per frame and reports the frame's cc_count, returning the primitive {Field1, Field2, CCCount} triple carriage consumes.

Flip timing. A pop-on caption is two transmissions — a build into non-displayed memory, then the EOC that flips it on screen — and both drain at one byte pair per frame, so the build takes real time (~18 pairs, 0.6 s at 30 fps, for two lines). What a pushed batch's TimeMS means is therefore a choice:

sched := schedule.NewScheduler(fps)                                    // FlipOnTime (default)
sched = schedule.NewScheduler(fps, schedule.WithFlipTiming(schedule.FlipAfterBuild))
  • FlipOnTime (default)TimeMS is when the caption becomes visible. The build is backdated so its EOC lands exactly on TimeMS. This is what a subtitle cue's start or a clock's second boundary actually means.
  • FlipAfterBuildTimeMS is when transmission starts, so the caption appears a build later: measured 0.37–0.43 s late at 30 fps, halving at 60 fps. The only behaviour before v0.8.0; go608-inject -no-preroll selects it.

A batch that does not end in an EOC — a bare EDM clear, a roll-up CRis the visible change and is never backdated. Nothing is dropped if a backdated build reaches past the preceding batch: the queue drains in order, so a crowded build starts as soon as the earlier pairs are done and its flip is merely late.

s := schedule.NewScheduler(30) // 30 fps → cc_count 20
s.Push(schedule.TimedTokens{TimeMS: 0, Tokens: tokens})

f := s.Frame(frameWallMS)      // ≤1 pair/field, padded to CCCount
nalu := carriage.FrameSEINALU(f.Field1, f.Field2, f.CCCount, carriage.CodecAVC)
  • cc_count per frame rate (CTA-708-E §4.3.6, round(600/fps)): 23.976/24→25, 25→24, 29.97/30→20, 50→12, 59.94/60→10. CCCountFull (the default) emits that full count and lets carriage pad the surplus with DTVCC padding; CCCountMinimal emits just the two 608 field constructs.
  • 608 rate cap: at ≤30 fps a frame carries one field-1 and one field-2 pair; above 30 fps only one 608 pair per frame (field 1 first).
  • Frame alignment: Serialize emits whole 2-byte pairs and Frame drains whole pairs, so a two-byte control code never straddles a frame. An idle field yields a 0-byte pair (distinct from the 0x80 0x80 608 null pair and from DTVCC padding).

Push takes schedule.TimedTokens (a wall-clock TimeMS plus a Field selector), not cue.TimedTokens — depending on cue would break the layering rule (schedule imports only cta608). See the package godoc.

A runnable schedulecarriage → decode round-trip lives in examples/.

Wall-clock generation (first milestone)

generate.Generator produces a wall-clock caption, driven one call per video frame with that frame's wall-clock time. The pull-by-wall-time model makes it robust to gaps, seeks, and variable frame rate, and makes drop-frame a non-issue.

g := generate.NewGenerator(30.0, generate.DefaultConfig()) // row14 UTC (white), row15 media (yellow)
for each video frame at wall-clock ms `w` {
    f := g.NextFrame(w)                                     // schedule.Frame{Field1, Field2, CCCount}
    nalu := carriage.FrameSEINALU(f.Field1, f.Field2, f.CCCount, carriage.CodecAVC)
    // consumer prepends the 4-byte length and splices `nalu` before the first VCL NALU
}

It builds each upcoming second's caption through the core (CaptionBlockEncoder → tokens) and drives a schedule.Scheduler. Cadence is one field-1 pair per frame (CC1 only by default); Config/LineSpec set the rows, colors, and content kinds. An overrun guard (Overran()) flags a line set that can't be written within the one-second budget at the given frame rate. A runnable N-second snippet lives in examples/.

A GeneratorOption picks how each second reaches the screen — the three CTA-608 caption modes, each with a different bargain between when the caption is complete and what the viewer sees on the way there:

modeoptioneach second
pop-on(default)built invisibly in non-displayed memory, flipped on whole by one EOC
paint-onWithPaintOn()cleared, then written onto the screen two characters per frame
roll-upWithRollUp(rows)window scrolls up, the new lines typed onto the base row

Pop-on (the default) is frame-accurate and zero-lag: the caption is built into non-displayed memory during a second and flipped on with a single EOC on that second's last frame, the flip pair scheduled eligible at exactly the flip time. Nothing is visible until it is all visible.

WithPaintOn() types the caption out instead. The generator then uses paint-on: each second opens with an EDM that clears the screen on its first frame, followed by RDC and the positioned rows written straight onto the displayed screen. Nothing is hidden in non-displayed memory, so the wire cadence becomes the animation — every frame's pair adds two characters (Serialize packs two per pair, the scheduler drains one pair per frame) and a decoder renders each as it arrives.

g := generate.NewGenerator(30.0, generate.DefaultConfig(), generate.WithPaintOn())
frame  0: 94 2c   EDM          the screen clears on the second's first frame
frame  1: 94 29   RDC          paint-on mode
frame  2: 94 52   PAC row 14  ─┐ position (indent 4)
frame  3: 97 a2   Tab 2       ─┘ + tab → column 6
frame  4: 32 b0   "20"        ─┐
frame  5: 32 b6   "26"         │ two characters per frame…
       ...                     ┘
frame 22: b0 b0   "00"           complete — it now stands until the next second's EDM

The default two lines take ~23 pairs, so at 30 fps the clock is written over 0.77 s of each second and stands for the rest; at 60 fps it is 0.38 s of the second. Overran() reports a caption that cannot be written within its second — a tighter budget than pop-on's, since the clear costs a pair and the next second's clear is what ends this one. Each second is painted from a clean screen and re-asserts RDC, so a decoder joining mid-stream is correct from the next second boundary. The trade-off is the mirror of pop-on's: pop-on hides the build and shows a whole caption late, paint-on shows every pair of progress but the text is incomplete for part of each second.

WithRollUp(rows) scrolls instead of clearing. Roll-up is the mode live captioning uses, and it types its text out the same way paint-on does — the difference is the boundary between seconds. There is no clear: a second is a CR (which scrolls the 2-4 row window up and empties the base row) followed by the new line, so earlier seconds age upward off the window.

How much of an earlier second you actually keep is worth being precise about, because it is easy to expect more than a small window holds. Every line is its own scroll step, so a cue of L lines consumes L of the rows rows: rows == L keeps no history — each second scrolls the previous one clean off — and a whole earlier second needs rows >= 2*L. For the default two lines that is WithRollUp(2) showing only the current second (also what the zero value and go608-clock -mode roll-up select), WithRollUp(3) keeping the previous second's bottom line, and WithRollUp(4) keeping the previous second complete. A window in mid-scroll shows rows from two seconds either way: the first line's CR scrolls before the last line lands, so the top rows hold the tail of the previous second for the duration of the remaining writes — about 0.27 s at 30 fps for the default.

g := generate.NewGenerator(30.0, generate.DefaultConfig(), generate.WithRollUp(3))
frame 30  94 26  RU3        window size (restated each second)
frame 31  94 ad  CR         scroll: row 14 → 13, row 15 → 14, base row cleared
frame 32  94 f4  PAC        base row, indent 8 (white)
frame 33  97 23  TO         Tab Offset 3 → column 11, centring "14:23:45Z"
frame 34  31 34  "14"       ─┐ the new UTC line types onto row 15…
frame 38  da 80  "Z"        ─┘ 5 pairs for 9 characters
frame 39  94 ad  CR         scroll again: the UTC line moves to 14
frame 40  94 f4  PAC        base row, indent 8
frame 41  91 2a  MidRow     yellow — its cell is the odd column centring needs
frame 42  cd 45  "ME"       ─┐ the media line types onto row 15
frame 48  b0 31  "01"       ─┘ settled: 13=MEDIA 00:00:00 14=14:23:45Z 15=MEDIA 00:00:01

Each configured line is its own scroll step, written in Row order so the window ends up laid out as the rows declare (the same picture pop-on and paint-on give); the largest Row is the base row. The history in the rows above is the decoder's and is never retransmitted, so a receiver joining mid-stream starts with a partly filled window that completes after ceil(rows/L) seconds for an L-line caption — for the default two lines, one second in a 2-row window and two in a 3- or 4-row one, exactly what tuning into a live broadcast looks like. Roll-up's control overhead is the mode entry once per cue plus a CR per line — 1+L pairs against paint-on's two (EDM + RDC) — so it costs L-1 pairs more than paint-on for an L-line caption: level at one line, +1 at two, +3 at four. That makes it the tightest budget of the three: the default two lines are 19 pairs, and 20 once a per-unit builder prepends its window reset, against the 24 a 25 fps second allows and the 23 of 23.976 fps. Those figures are why the default UTC line is time-of-day rather than a full RFC3339 timestamp — the date cost 5 pairs and put this mode over budget at both rates. See W9 for the measurements.

Per-unit cues (BuildUnitCues)

generate.BuildUnitCues is the segment-oriented counterpart to Generator: one call per unit — a DASH segment, a MoQ group — instead of one call per frame, which is what a stateless server generating segments on demand needs. It splits the unit into N = NumCues(unitDurMS, targetPeriodMS) equal cue slices, asks a CueContentFunc for each slice's lines, and returns one schedule.Frame per video frame.

NumCues divides down, so a cue is never shorter than the period you asked for: max(1, unitDurMS/targetPeriodMS) truncated. A 2.002 s segment (60 frames at 30000/1001) gets its two 1.001 s cues, and a 1.001 s segment one; a 1.92 s segment gets one 1.92 s cue rather than two of 0.96 s. The asymmetry matters when the content's resolution is the period — a clock labelled in whole seconds, which is what WallClockContent renders. Two cues starting inside the same second render identically, so the second one has nothing to flip and the caption ends up displayed up to a period after the instant it names. Dividing down spends cue rate to keep every caption true when it appears. A unit shorter than one period still gets one cue: that is the one case the trade cannot be made, and consecutive short units can then repeat a label (a 30-frame group at 60000/1001 is 501 ms, so the clock stays within half a second of true).

There is one builder per caption mode, all taking the same Unit and CueContentFunc: BuildUnitCues (pop-on), BuildUnitPaintCues and BuildUnitRollUpCues. They differ only in how a cue reaches the screen and in what a unit owes the units around it — generate.WallClockContent(cfg, originMS) renders the same wall clock the Generator does, if that is the caption you want served per unit.

A unit is described by a Unit, whose three fields are independent inputs:

type Unit struct {
    Nr      int64 // unit/segment/group number, as you number them
    StartMS int64 // wall-clock time of the unit's first frame
    Frames  int   // video frames in the unit
}

frames, err := generate.BuildUnitCues(fps, generate.Unit{Nr: 42, StartMS: 84_000, Frames: 60},
    1000, content)

A unit's start is not assumed to be Nr × duration. Segment durations vary (a $Time$ timeline), timelines contain gaps, and a numbering epoch need not begin at t=0, so nothing derives one field from the other: StartMS is the only thing timing is measured from, and Nr is passed through to your CueContentFunc untouched.

type CueContentFunc func(u generate.Unit, cueIdx int, cueStartMS int64) generate.UnitCue

Because the unit arrives as an argument rather than being closed over, one CueContentFunc serves every unit — including the next unit's first cue under WithFlipAtCueStart below.

Frame i belongs to the sample with the i-th smallest presentation time — the i-th displayed frame of the unit, not the i-th sample in decode order. The two differ whenever the video reorders in the container (B-frames in AVC/HEVC; AV1 reorders inside the bitstream instead, so for it they always coincide). Walking samples in decode order scrambles the captions, and reading them back the same way hides it — go-608's own tools had exactly this bug.

A pop-on cue is two transmissions — a build (RCL + ENM + rows) written into non-displayed memory, and an EOC that flips it on screen — and both drain at one 608 pair per frame. Where the build sits therefore decides when the caption appears, and that is a genuine trade-off:

Default: self-contained units. The build starts at its cue's first frame and the flip follows it, so every build and flip stays inside the unit and each unit is independently decodable. The cost is arming latency: a two-line build is ~15-19 pairs, so the caption reaches the screen 0.5-0.75 s into the ~1 s slice at 30 fps, and is visible only for the remainder. Fine when the text is unrelated to its own display time.

WithFlipAtCueStart(next): flips on the cue boundary. Each EOC moves onto the first frame of its own cue and the build is transmitted over the frames before the flip, so the caption is displayed over exactly the interval its content names. Use it when the text refers to its own display interval — a clock, a segment or group number — since with the default placement such a caption is always seen late.

frames, err := generate.BuildUnitCues(fps, unitA, 1000, content,
    generate.WithFlipAtCueStart(unitB, content))

The build for a cue then lives in the frames preceding its flip, which for a unit's first cue means the previous unit. So WithFlipAtCueStart names the following unit outright, and this unit's tail carries the build for that unit's first cue — resolved as content(unitB, 0, unitB.StartMS). Only Nr and StartMS are read; pass a nil content to leave the tail empty.

unitB.StartMS is taken as given, not computed as unit A's end, so a gap between units or a change of duration needs no special case — say where the next unit actually starts. It must be the same Unit (and content function) the next BuildUnitCues call receives, since the build placed in A's tail has to match the flip B emits. Consecutive units are still generated independently — a unit's first cue is always encoded from a clean encoder state, so the build one unit places in its tail matches the flip the next unit emits. On the wire, at 30 fps with ~1 s cues:

unit A frame 45: 94 20   RCL   ─┐ build of unit B's first cue
unit A frame 46: 94 ae   ENM    │ (one pair per frame)
       ...                      │
unit A frame 59: 34 b3         ─┘ ends on A's last frame
unit B frame  0: 94 2f   EOC      the flip, on the cue boundary

What this costs at a discontinuity. Units are no longer self-contained, so a receiver that starts, seeks, or joins mid-stream gets that leading EOC without the build that belongs to it. What it then shows depends on its decoder state: a fresh decoder has empty non-displayed memory and shows nothing for one cue period, while a decoder that keeps 608 state across the discontinuity flips whatever was last preloaded and can show one cue period of stale caption before correcting at the next boundary. Recovering faster is the receiver's job: a player that resets its 608 state at a discontinuity, as it would any other decoder, turns the stale case into the blank one. A sender cannot help — any pair emitted ahead of the EOC to sanitise the state (an ENM) would erase the very build about to be flipped. Choose the default placement if that matters more than display accuracy.

Either way the 608 data rate is one pair per frame, so cc_count stays round(600/fps), and a build that does not fit the frames available to it is a returned error rather than a silently dropped build (which would leave an EOC with nothing loaded).

Paint-on cues (BuildUnitPaintCues)

generate.BuildUnitPaintCues takes the same arguments as BuildUnitCues — same Unit, same CueContentFunc, same slicing — but paints each cue onto the screen instead of popping it on:

frames, err := generate.BuildUnitPaintCues(fps, generate.Unit{Nr: 42, StartMS: 84_000, Frames: 60},
    1000, content)

Each cue is one batch — EDM (clear), RDC (paint-on), then the positioned rows — eligible at its slice's first frame. Draining it one pair per frame is the animation: the screen goes blank on the boundary, fills two characters at a time, and holds until the next cue's clear. A 2 s segment at 30 fps, two lines of ~12 characters:

frame  0  94 2c  EDM       screen clears
frame  4  31 34  "14"      ─┐
frame  9  b0 b0  "…000"     │ row 14 written out
frame 14  34 32  "SEG 42"  ─┘ complete at frame 14 of 30
frames 15-29     idle        the whole caption stands on screen
frame 30  94 2c  EDM       the next cue clears and repaints

The trade-off against pop-on is the same one WithPaintOn makes for Generator, plus one structural gain: a paint-on unit is always self-contained. No cue's data crosses a unit boundary, every unit opens by clearing the screen and re-asserting RDC, and a receiver that joins mid-stream is correct from the first cue boundary it sees — so there is no WithFlipAtCueStart here, and none of its discontinuity cost, while the caption is still displayed over the interval its text names. What you give up is readability time: a cue's text is only complete for the tail of its slice (0.5 s of a 1 s slice for the example above), so paint-on suits a caption whose arrival is the point — a visible liveness tell that stalls the moment the stream does. A cue that cannot finish painting inside its slice with a frame to spare is a returned error.

Roll-up cues (BuildUnitRollUpCues)

generate.BuildUnitRollUpCues is the roll-up counterpart: same Unit, same CueContentFunc, plus the window size. Each cue is the RU2/3/4 mode entry followed by a CR and the typed text for each of its lines, eligible at its slice's first frame.

frames, err := generate.BuildUnitRollUpCues(fps, unit, 1000, 3, content)               // reset (default)
frames, err := generate.BuildUnitRollUpCues(fps, unit, 1000, 3, content,
    generate.WithRollUpCarry())                                                        // keep the window

What happens between cues is the whole question, because roll-up is the one mode that defines only the new line and leaves the rest of the window to the decoder:

  • Default — reset. The unit opens with an EDM on its first frame, so the window starts empty and refills from the unit's own cues. The unit is then self-contained in display as well as in data: a receiver that joins, seeks, or starts here sees exactly what a continuously-running one sees, and go-608 can promise that from the unit alone. The cost is visible — the window truncates and refills at every unit boundary, so the deepest it ever gets is the L*N rows a unit writes for itself, L lines per cue over its N = NumCues cues. Two one-second cues of two lines fill a 4-row window exactly; a single-line caption in the same 2 s unit reaches only 2 of those 4 rows.
  • WithRollUpCarry(). The window scrolls smoothly across boundaries and fills to its full depth, as broadcast roll-up does. The cost is that the display depends on units arriving in order: a joining receiver sees a thin window that completes after ceil(rows/L) cues, and a seek shows the pre-seek lines aging out over the same span. Both self-correct.

Reset is the default because it is the only option whose output is a function of the unit alone — the property the per-unit API exists to provide. Either way the emitted data is identical between the two apart from that one EDM, so a stateless server can serve either without tracking state. An empty cue (UnitCue{}) emits nothing and leaves the window standing; there is no clear to emit, since an empty roll-up cue is silence rather than an erase.

Timed-text cues (the cue package)

The cue package is the shared timed-text intermediate and the one place the 608↔cue mapping is written. WebVTT and SRT (and future formats like TTML) are thin serializers over it. A TimedCue{Start, End, Content} reuses the core cta608.Screen as its Content, so every format pivots on positioned, styled rows. The mapping is lossy and Screen-mediated — a sibling of the byte-exact SCC/SEI containers — because a format's richer grid and palette are quantized to 608's 15×32 grid and 8 colors at the serializer edge.

608 → text — Segment. A timeline of displayed-Screen states (TimedScreen, sampled whenever Decoder.Changed fires) is cut into cues by one unified rule for every caption mode: each displayed-Screen change closes the current cue and opens a new one; an empty screen is a gap (no cue). A caption still shown when the stream ends takes a configurable end — SegmentOptions.StreamEnd when set, else Start + DefaultDur.

Direct-write modes need a coalescing rule. Pop-on builds into non-displayed memory, so its display changes once per caption and one cue falls out for free. Roll-up and paint-on write straight to the displayed screen, so every byte pair changes it — a bare screen-change rule would cut a cue per two characters, giving 14 one-frame cues for two roll-up lines. SegmentOptions.Coalesce picks the boundary:

cue.Segment(changes, cue.SegmentOptions{})                             // CoalesceStructural
cue.Segment(changes, cue.SegmentOptions{Coalesce: cue.CoalesceNone})   // per change
  • CoalesceStructural (default) cuts only at a structural event — a scroll, an erase, a jump to another row, an overwrite — and never while text is merely being added to a row. Pop-on gives one cue per caption, roll-up one cue per scroll step (the visible lines repeat as the window scrolls), paint-on one per write burst. A period's cue starts at its first change and carries the screen as of its last, so the completed caption is displayed from the moment its first characters appeared; timestamping at completion instead would leave the typing interval in a gap.
  • CoalesceNone cuts at every change: the faithful rendering of what a viewer sees, two characters at a time, and the only mode needing no lookahead.

Coalescing is gated on the caption mode, carried by TimedScreen.Mode from cta608.Decoder.Mode(). By screen alone a pop-on caption replaced by a longer one is indistinguishable from a line being typed, so without the mode the rule would silently merge two distinct captions. TimedScreen.Mode's zero value is pop-on, which never coalesces.

text → 608 — Compile. Every cue compiles to a pop-on caption. Overlapping cues are merged by position at each boundary: the target Screen is the union of all active cues' Screens, placed by row, with a same-row conflict resolved by cue order (the later cue wins that row). That target drives the core Encoder, whose diff engine re-flips the caption whenever the active set changes. Compile stops at wall-time-tagged token transitions (TimedTokens); mapping them onto frames is schedule's job.

// 608 -> text
cues := cue.Segment(screens, cue.SegmentOptions{DefaultDur: 2 * time.Second})

// text -> 608
for _, tt := range cue.Compile(cues) { /* tt.Time, tt.Tokens -> schedule */ }

The Reader/Writer interface over []TimedCue is the published plugin seam: webvtt and srt implement it in-tree, and TTML or third-party formats plug in with zero change to the mapping. Runnable snippets for both directions live in examples/.

SRT (SubRip text)

The srt package is a thin, two-way serializer over the cue model — the simpler sibling of webvtt. SRT is a header-less list of numbered blocks (an index line, a HH:MM:SS,mmm --> HH:MM:SS,mmm timing line, and one or more text lines, blank-line separated) with light inline styling and no standard positioning. All 608↔cue logic lives in cue, so srt only maps SRT text ⇄ []cue.TimedCue; it imports only cue/cta608 and the standard library.

cues, _ := srt.Read(r)   // parse .srt -> cues (implements cue.Reader)
srt.Write(w, cues)       // cues -> .srt   (implements cue.Writer)
  • Styling, quantized to 608's 8 colors (design note W5). Out: foreground color → <font color="#rrggbb">, italic → <i>, underline → <u>; background is dropped (SRT has none) and bold is never emitted (it has no 608 source). In: a <font> color (hex or a CSS keyword) → the nearest of the 8 608 colors, <i>/<u> honored, <b> dropped, and unknown tags stripped.
  • Positioning: SRT has none, so 608→SRT renders bottom-centered (row/column placement dropped) and SRT→608 anchors text to the bottom-center of the grid. No {\anX} or coordinate extensions are invented — SRT stays at its portable common denominator (design note W6).
  • Round-trip is semantic, not byte-exact: colors snap to the palette and placement collapses to bottom-center, but a read → write → read cycle is stable.

A runnable .srt ↔ cues snippet lives in examples/, and sample files in testdata/srt/.

WebVTT (webvtt)

The webvtt package is a thin serializer over cue — the richer sibling of srt. It owns only WebVTT syntax and its styling/positioning quantization; every 608↔cue decision lives in cue, so webvtt imports only cue/cta608 and the standard library. Because it maps a rich format onto 608's coarse grid and 8-color palette, Read → Write is a semantic, quantized round-trip, not byte-exact (the lossy sibling of the SCC/SEI containers).

cues, _ := webvtt.Read(r)  // WEBVTT text -> []cue.TimedCue (implements cue.Reader)
webvtt.Write(w, cues)      // []cue.TimedCue -> WEBVTT text (implements cue.Writer)
  • Structure: the WEBVTT magic header, optional STYLE/NOTE blocks, and cue blocks with HH:MM:SS.mmm --> HH:MM:SS.mmm timing (the .-millisecond form), optional line:/position:/align: settings, and styled payload text. One cue block maps to one TimedCue whose Content is a Screen: each payload line is a Row, each maximal same-style span a Run.
  • Styling (design note W5). Out: a non-white foreground becomes a <c.name> class plus a STYLE rule (::cue(.red) { color: #ff0000; }), italic/underline become <i>/<u>, and a background is best-effort via a bg_name class and a ::cue background rule. In: any class/STYLE/#hex/rgb() color quantizes to the nearest of the 8 608 colors, <i>/<u> are honored, bold (<b>) is dropped, and voice/lang/unknown tags are stripped.
  • Positioning (design note W6). line:Row.Index (1–15) and position:/align: ⇄ the leftmost Run.Column/indent, quantized to the grid so the round-trip is approximate. A position-less cue anchors bottom-center.
  • Timing is exact, not quantized-approximate — it is the styling and positioning that are lossy. A cue's start survives a WebVTT → 608 → WebVTT round-trip to the frame, because the pop-on build is pre-rolled so the EOC lands on the cue boundary (see Scheduling); ends are exact because the clearing EDM is the visible change. Measured on a 30 fps round-trip: 1.000/3.000/5.000 → 1.000/3.000/5.000, back-to-back cues included, and stable across repeated conversions. With -no-preroll the same input returns 1.433/3.467/5.367 and drifts further on each pass.

Sample fixtures live in testdata/webvtt/ and runnable .vtt ↔ cue snippets in examples/.

SCC (Scenarist SCC read/write)

The scc package is a byte-pair container — a sibling of the SEI carriage. It owns the SCC text-file structure and timecodes only; the cta608 core owns all 608 semantics, so Read → Write is byte-exact. It imports only cta608 and the standard library.

f, _ := scc.Read(r)                 // infers FPS/DropFrame; WithFPS(…) overrides
for _, p := range f.TimedPairs() {  // pair i of an entry lands at Frame+i
    // p.Frame, p.Pair (2 bytes) — feed the concatenated channel bytes to cta608.Parse
}
scc.Write(w, f)                     // dumb: one Entry -> one line, verbatim
  • Model: SCCFile{FPS, DropFrame, Entries} with Entry{Frame, Pairs} — an absolute frame number plus its verbatim raw byte pairs. Canonical time is an absolute integer frame counted from 00:00:00:00.
  • True SMPTE drop-frame (FrameToTimecode/TimecodeToFrame): for 29.97/59.94 the frame labels 0,1 (0..3 at 59.94) are skipped at the top of every minute except every tenth minute — the conversion the media-tools/SVTA prior art gets wrong. PAL(25) and the integer rates are always non-drop. ; before the frame field marks drop-frame, : non-drop; both are accepted.
  • fps inference (Read): SCC is a sparse event list, so the reader infers the rate from the timecodes — a ; separator means drop-frame, and the maximum line-start frame field selects the family (≥50 → 59.94/60; 30–49 → 50; 25–29 → 29.97/30; ≤24 → ambiguous). WithFPS overrides; genuinely ambiguous files fall back to 29.97 (the NTSC default).
  • Dumb writer (Write): one Entry → one line, verbatim — the caller decides what pairs sit on each line. GroupPairs is an optional helper (not the writer) that coalesces a flat scheduled stream into sparse entries, breaking at idle gaps; it is the inverse of TimedPairs.

A runnable read → tokens → write-back snippet lives in examples/, and sample ;/: files in testdata/scc/.

Command-line tools

ToolPurpose
go608-clockGenerate a wall-clock caption and splice it into an mp4.
go608-infoDump cc_data / tokens / rendered Screen from a file or bytes.
go608-extractmp4 with 608 → WebVTT / SRT / SCC (format-only conversion is a mode).
go608-injectWebVTT / SRT / SCC → mp4 with 608 SEI (format-only conversion is a mode).

Each tool supports --version (stamped from git via -ldflags at build time).

go608-clock

The first-milestone demo. It runs the whole encode spine — generate.NextFrame (or, under -unit-mode, generate.BuildUnit*Cues) → carriage.FrameSEINALU → splice the bare SEI NAL before the first VCL NAL of each frame — and writes a fragmented mp4 whose frames carry the wall-clock caption.

# Self-contained synthetic AVC fMP4 (placeholder video, real 608 SEI):
go608-clock -o clock.mp4 -fps 30 -seconds 5

# Splice the caption into every frame of real video (AVC or HEVC, auto-detected),
# preserving the input's sample timing:
go608-clock -i input.mp4 -o captioned.mp4 -fps 25

# Custom caption lines (repeatable "row:color:kind"; kind is "utc" or "media"):
go608-clock -o clock.mp4 -line 14:white:utc -line 15:yellow:media

# Paint-on: clear each second and type the caption out, two characters per frame:
go608-clock -o clock.mp4 -mode paint-on -seconds 5

# Roll-up: scroll a 3-row window each second and type onto the bottom row:
go608-clock -o clock.mp4 -mode roll-up3 -seconds 5

# Per-unit generation (the segment-server API) with 2 s units, and its cross-unit
# policies: pop-on flipping on the cue boundary, roll-up keeping its window:
go608-clock -o clock.mp4 -unit-mode cue-start -unit-seconds 2 -seconds 6
go608-clock -o clock.mp4 -mode roll-up3 -unit-mode carry -unit-seconds 2

Flags: -o (output, required), -i (input fMP4; omit for synthetic frames), -fps (default 30; also drives caption cadence and the wall-clock advance), -seconds (synthetic duration), -start (RFC3339 wall-clock start; default now UTC), -line (repeatable line config; default row 14 UTC white, row 15 media yellow), -mode (pop-on, the default, paint-on, or roll-up[2-4] — see Wall-clock generation), -unit-mode + -unit-seconds (below), and -version.

-unit-mode drives the per-unit API instead of the frame-by-frame Generator, so the demo exercises what a stateless segment server calls — BuildUnitCues and its paint-on and roll-up siblings — over units of -unit-seconds (default 2). It also reaches the two cross-unit policies that exist only there:

-unit-modewitheffect
(unset)anycontinuous Generator, one call per frame
defaultanyper-unit, each unit's cues placed inside it
cue-start-mode pop-onWithFlipAtCueStart: each flip on its cue boundary, the build carried in the previous unit's tail
carry-mode roll-up*WithRollUpCarry: keep the roll-up window across unit boundaries instead of clearing it

A mismatch (-unit-mode carry with pop-on, say) is an error rather than a silently ignored flag. Units tile the run from its first frame and every one of them is a whole unit; a run that does not end on a unit boundary is cut mid-unit, exactly as a stream stopping mid-segment is. The run length need not be a multiple of -unit-seconds, which matters for -i, where the sample count is whatever the input has.

Without -i the output is a structurally valid fMP4 with placeholder video payloads — ideal for round-tripping the 608; pass -i to caption decodable video. If a line set can't be written within one second at the chosen frame rate, the tool reports an overrun — a warning from the continuous generator, whose guard is sticky, and a hard error under -unit-mode, where each unit's builder refuses content that will not fit its slice. The shared mp4 read/write and NAL-splice glue lives in internal/mp4io (reused by the other mp4 tools).

go608-info

The debug dumper — the thinnest consumer of the decode spine (carriage.FieldPairscta608.Parse / cta608.Decoder). For a fragmented mp4 or a raw cc_data byte-pair stream it prints three line-oriented sections: the per-unit field byte pairs, the parsed token stream, and the rendered Screen at each displayed change. Output is deterministic (no timestamps) so it greps and diffs cleanly.

# Dump an mp4's 608: field pairs, tokens, and screens per displayed change:
go608-info -i captions.mp4

# Decode field 2 (CC3/CC4) instead of the default field 1 (CC1):
go608-info -i captions.mp4 -field 2

# Dump a raw cc_data byte-pair stream directly, no mp4 needed
# (spaces, commas, and "0x" prefixes are all accepted):
go608-info -hex "9420 94ae 9162 c849 942f"

# Read the byte pairs from a file:
go608-info -cc-file pairs.txt

Flags: -i (input fragmented mp4), -hex (inline hex byte pairs), -cc-file (a file of hex byte pairs) — pass exactly one; -field (1 or 2; the field that drives the token parse and the Decoder, default 1); and -version. For an mp4 both fields' bytes are always listed; the selected field is parsed and decoded.

go608-extract and go608-inject

The two integration capstones — the decode and encode ends of the whole stack. go608-extract pulls 608 out of a fragmented mp4 and writes WebVTT, SRT, or SCC; go608-inject reads WebVTT/SRT/SCC and splices 608 back into an mp4. Both share one conversion core (internal/convert): format-only conversion (SCC ⇄ WebVTT ⇄ SRT, no mp4) is a mode of each, not a fifth binary.

# Extract to each format (SCC is byte-exact; WebVTT/SRT are faithful, quantized):
go608-extract -i captioned.mp4 -o out.vtt
go608-extract -i captioned.mp4 -to scc > out.scc
go608-extract -i captioned.mp4 -dump             # go608-info-style dump

# Inject subtitles into an mp4 (WebVTT/SRT are compiled; SCC rides byte-exact):
go608-inject -i video.mp4 -sub captions.srt -o captioned.mp4 -fps 30
go608-inject -i video.mp4 -sub captions.scc -o captioned.mp4 -fps 29.97

# Format-only conversion (no mp4) — the shared mode:
go608-extract -i captions.scc -o captions.vtt
go608-inject  -sub captions.srt -to scc -fps 30 > captions.scc

The mapping mirrors the format classes: mp4 SEI and SCC are byte-pair siblings, so 608 ↔ SCC is byte-exact (raw wire pairs, no re-encode); WebVTT/SRT are cue-mediated, so those directions decode/Segment (out) or Compile/schedule (in) and are faithful but quantized to the 608 grid and palette. Extract's -stream-end/-default-dur set the dangling-cue policy; inject's -fps and -cc-count (full/minimal) size the per-frame cadence; both infer the format from the file extension (override with -from/-to).

Two flags select a timing/segmentation policy, each restoring pre-v0.8.0 behaviour:

flagoneffect
-no-prerollgo608-inject, go608-extracttransmit each pop-on build starting at its cue time instead of ahead of it, so the caption appears ~0.2–0.5 s late (see Scheduling)
-per-changego608-extractfor roll-up/paint-on input, emit one cue per displayed-screen change — roughly one per two characters — instead of one per scroll step (see Timed-text cues)

Because a WebVTT cue's start is when the caption is displayed while an SCC entry's timecode is when its first byte pair is transmitted — and those differ by exactly the pop-on build, which is now pre-rolled — SCC → WebVTT → SCC returns the original timecodes. The only asymmetry left is the terminating erase Compile appends for a caption the source never cleared.

Building

Requires Go 1.25+.

make all      # check (vet + lint) + build + test
make build    # go build ./... and the four cmd binaries into ./out
make test     # go test ./...
make coverage # coverage profile + function summary

The build stamps version and commit date into internal via -ldflags; make lint runs golangci-lint when it is installed (CI always enforces it).

Development

  • Documentation is versioned: the detailed reference lives in this README and in the package documentation (pkg.go.dev); CHANGELOG.md stays terse.
  • Optional pre-commit hooks: pip install pre-commit && pre-commit install (see .pre-commit-config.yaml).
  • CI runs four workflows on every PR: Go (build + test on Linux/macOS/Windows), Coverage, golangci-lint, and govulncheck.

License

MIT © 2026 Eyevinn Technology AB.

ChangeLog and Versions

See CHANGELOG.md.

Support

Join our community on Slack where you can post any questions regarding any of our open source projects. Eyevinn's consulting business can also offer you:

  • Further development of this component
  • Customization and integration of this component into your platform
  • Support and maintenance agreement

Contact sales@eyevinn.se if you are interested.

About Eyevinn Technology

Eyevinn Technology is an independent consultant firm specialized in video and streaming. Independent in a way that we are not commercially tied to any platform or technology vendor. As our way to innovate and push the industry forward we develop proof-of-concepts and tools. The things we learn and the code we write we share with the industry in blogs and by open sourcing the code we have written.

Want to know more about Eyevinn and how it is to work here. Contact us at work@eyevinn.se!