Inject subtitles into an mp4 (WebVTT/SRT are compiled; SCC rides byte-exact):
August 4, 2026 · View on GitHub
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.
| Package | Responsibility |
|---|---|
cta608 | Pure core: Token stream, Screen, Serialize/Parse, Decoder/Encoder, CaptionBlock. A dependency-free leaf. |
carriage | cc_data / T.35 carriage: SEI + NAL for AVC & HEVC, metadata OBU for AV1. The only mp4ff importer. |
schedule | Timed tokens → per-frame {Field1, Field2, CCCount}. The shared timing layer. |
generate | Wall-clock caption Generator (per frame) and per-unit builders (per segment / MoQ group), in pop-on, paint-on or roll-up. |
scc | Scenarist SCC read/write — byte-exact, true SMPTE drop-frame. |
cue | Shared TimedCue intermediate + the 608↔cue mapping + a plugin seam. |
webvtt | WebVTT serializer over the cue model. |
srt | SRT 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):
| Token | Meaning |
|---|---|
Chars | a run of characters (standard, special, and extended glyphs) |
PAC | Preamble Address Code: row + base pen, or row + indent |
MidRow | mid-row style change (color/underline/italics) |
TabOffset | shift the cursor 1–3 columns |
BackgroundAttr | background color / transparent bg / black-foreground |
SetMode | pop-on (RCL), roll-up (RU2/3/4), or paint-on (RDC) |
Command | misc 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 emitsEDM. - 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 withCR. - paint-on — enter with
RDCand 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) → Tab →
MidRow(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) —TimeMSis when the caption becomes visible. The build is backdated so itsEOClands exactly onTimeMS. This is what a subtitle cue's start or a clock's second boundary actually means.FlipAfterBuild—TimeMSis 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-prerollselects it.
A batch that does not end in an EOC — a bare EDM clear, a roll-up CR — is 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_countper 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 letscarriagepad the surplus with DTVCC padding;CCCountMinimalemits 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:
Serializeemits whole 2-byte pairs andFramedrains whole pairs, so a two-byte control code never straddles a frame. An idle field yields a 0-byte pair (distinct from the0x80 0x80608 null pair and from DTVCC padding).
Pushtakesschedule.TimedTokens(a wall-clockTimeMSplus aFieldselector), notcue.TimedTokens— depending oncuewould break the layering rule (scheduleimports onlycta608). See the package godoc.
A runnable schedule → carriage → 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 (CaptionBlock →
Encoder → 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:
| mode | option | each second |
|---|---|---|
| pop-on | (default) | built invisibly in non-displayed memory, flipped on whole by one EOC |
| paint-on | WithPaintOn() | cleared, then written onto the screen two characters per frame |
| roll-up | WithRollUp(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
EDMon 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 theL*Nrows a unit writes for itself,Llines per cue over itsN = NumCuescues. 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 afterceil(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.CoalesceNonecuts 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
WEBVTTmagic header, optionalSTYLE/NOTEblocks, and cue blocks withHH:MM:SS.mmm --> HH:MM:SS.mmmtiming (the.-millisecond form), optionalline:/position:/align:settings, and styled payload text. One cue block maps to oneTimedCuewhoseContentis aScreen: each payload line is aRow, each maximal same-style span aRun. - Styling (design note W5). Out: a non-white foreground becomes a
<c.name>class plus aSTYLErule (::cue(.red) { color: #ff0000; }), italic/underline become<i>/<u>, and a background is best-effort via abg_nameclass and a::cuebackground 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) andposition:/align:⇄ the leftmostRun.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
EOClands on the cue boundary (see Scheduling); ends are exact because the clearingEDMis 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-prerollthe same input returns1.433/3.467/5.367and 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}withEntry{Frame, Pairs}— an absolute frame number plus its verbatim raw byte pairs. Canonical time is an absolute integer frame counted from00: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).WithFPSoverrides; genuinely ambiguous files fall back to 29.97 (the NTSC default). - Dumb writer (
Write): oneEntry→ one line, verbatim — the caller decides what pairs sit on each line.GroupPairsis an optional helper (not the writer) that coalesces a flat scheduled stream into sparse entries, breaking at idle gaps; it is the inverse ofTimedPairs.
A runnable read → tokens → write-back snippet lives in examples/,
and sample ;/: files in testdata/scc/.
Command-line tools
| Tool | Purpose |
|---|---|
go608-clock | Generate a wall-clock caption and splice it into an mp4. |
go608-info | Dump cc_data / tokens / rendered Screen from a file or bytes. |
go608-extract | mp4 with 608 → WebVTT / SRT / SCC (format-only conversion is a mode). |
go608-inject | WebVTT / 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-mode | with | effect |
|---|---|---|
| (unset) | any | continuous Generator, one call per frame |
default | any | per-unit, each unit's cues placed inside it |
cue-start | -mode pop-on | WithFlipAtCueStart: 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.FieldPairs → cta608.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:
| flag | on | effect |
|---|---|---|
-no-preroll | go608-inject, go608-extract | transmit 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-change | go608-extract | for 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.mdstays 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!