go608

package module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 28, 2026 License: MIT Imports: 0 Imported by: 0

README

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.

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 (the first milestone).
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 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. 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/.

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.

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. The caption is pop-on: built into non-displayed memory during a second and flipped on with a single EOC on that second's last frame, so the clock is frame-accurate and zero-lag (the flip pair is scheduled eligible at the flip time). 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 finish building within the one-second budget at the given frame rate. A runnable N-second snippet lives in examples/.

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.

frames, err := generate.BuildUnitCues(fps, unitFrames, unitStartMS, 1000, content)

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, unitFrames, unitStartMS, 1000, content,
    generate.WithFlipAtCueStart(nextContent))

The build for a cue then lives in the frames preceding its flip, which for a unit's first cue means the previous unit. next supplies the following unit's first cue so this unit's tail can carry its build; it is called once as next(0, unitStartMS+unitDurMS), and nil leaves the tail empty. 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).

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). Pop-on gives one cue per caption, roll-up one cue per scroll step (the visible lines repeat as the window scrolls), paint-on a cue per in-place change. A caption still shown when the stream ends takes a configurable end — SegmentOptions.StreamEnd when set, else Start + DefaultDur.

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.

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

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.NextFramecarriage.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

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), and -version. 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 build within one second at the chosen frame rate, the tool reports an overrun. 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).

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!

Documentation

Overview

Package go608 is the documentation landing page for the go-608 module — a Go library for CTA-608 / CEA-608 captions: encode + decode, cc_data + SEI carriage per ATSC A/53, wall-clock caption generation, and timed-text (SCC / WebVTT / SRT) I/O.

The module has no importable root package (the last path element "go-608" is not a valid Go identifier). This file exists only to provide a godoc landing page. Import the cooperating packages directly:

  • cta608 the pure core: Token stream, Screen, Serialize/Parse, Decoder/Encoder
  • carriage cc_data / T.35 / SEI / NAL carriage (wraps github.com/Eyevinn/mp4ff)
  • schedule timed tokens to per-frame {field1, field2, ccCount}
  • generate wall-clock caption generation (the first milestone)
  • scc Scenarist SCC read/write (byte-exact, true SMPTE drop-frame)
  • cue shared timed-cue intermediate and the 608<->cue mapping
  • webvtt WebVTT serializer over the cue model
  • srt SRT serializer over the cue model

See SPEC.md at the repository root for the full design specification and docs/design/ for the per-decision rationale.

Directories

Path Synopsis
Package carriage carries CTA-608 caption data as cc_data() inside AVC/HEVC SEI or an AV1 metadata OBU, wrapping github.com/Eyevinn/mp4ff for the SEI/NAL and OBU layers.
Package carriage carries CTA-608 caption data as cc_data() inside AVC/HEVC SEI or an AV1 metadata OBU, wrapping github.com/Eyevinn/mp4ff for the SEI/NAL and OBU layers.
cmd
go608-clock command
Command go608-clock generates a wall-clock CTA-608 caption and splices it as SEI into a fragmented mp4 — the go-608 first-milestone demo (SPEC §7).
Command go608-clock generates a wall-clock CTA-608 caption and splices it as SEI into a fragmented mp4 — the go-608 first-milestone demo (SPEC §7).
go608-extract command
Command go608-extract pulls CTA-608 captions out of a fragmented mp4 and writes them as WebVTT, SRT, or SCC — the decode-side integration capstone (SPEC §6/§8 §9).
Command go608-extract pulls CTA-608 captions out of a fragmented mp4 and writes them as WebVTT, SRT, or SCC — the decode-side integration capstone (SPEC §6/§8 §9).
go608-info command
Command go608-info dumps the CTA-608 decode stack for a fragmented mp4 or raw cc_data byte pairs: the per-unit field byte pairs, the parsed token stream, and the rendered Screen at each displayed change.
Command go608-info dumps the CTA-608 decode stack for a fragmented mp4 or raw cc_data byte pairs: the per-unit field byte pairs, the parsed token stream, and the rendered Screen at each displayed change.
go608-inject command
Command go608-inject injects CTA-608 into a fragmented mp4 from WebVTT, SRT, or SCC input — the encode-side integration capstone (SPEC §6/§8 §9).
Command go608-inject injects CTA-608 into a fragmented mp4 from WebVTT, SRT, or SCC input — the encode-side integration capstone (SPEC §6/§8 §9).
Package cta608 is the pure core of go-608: the CTA-608 / CEA-608 caption model and its wire boundary.
Package cta608 is the pure core of go-608: the CTA-608 / CEA-608 caption model and its wire boundary.
Package cue is the shared timed-text intermediate for go-608 and the one place the 608<->cue mapping is written (SPEC §4.5 / §8.2, design note docs/design/cea608-webvtt-srt-io.md).
Package cue is the shared timed-text intermediate for go-608 and the one place the 608<->cue mapping is written (SPEC §4.5 / §8.2, design note docs/design/cea608-webvtt-srt-io.md).
Package examples holds runnable, self-contained snippets demonstrating the go-608 packages.
Package examples holds runnable, self-contained snippets demonstrating the go-608 packages.
Package generate produces wall-clock CTA-608 captions — the first milestone consumed by livesim2 and moqlivemock.
Package generate produces wall-clock CTA-608 captions — the first milestone consumed by livesim2 and moqlivemock.
Package internal holds build-time version stamping and shared command helpers that are deliberately kept out of go-608's public API.
Package internal holds build-time version stamping and shared command helpers that are deliberately kept out of go-608's public API.
convert
Package convert is the shared timed-text conversion core behind go608-extract and go608-inject.
Package convert is the shared timed-text conversion core behind go608-extract and go608-inject.
dump
Package dump renders the CTA-608 decode stack — field byte pairs, the parsed token stream, and the rendered Screen at each displayed change — as a deterministic, line-oriented text report.
Package dump renders the CTA-608 decode stack — field byte pairs, the parsed token stream, and the rendered Screen at each displayed change — as a deterministic, line-oriented text report.
mp4io
Package mp4io holds the shared fragmented-mp4 muxing glue used by the go-608 commands: go608-clock and go608-inject splice per-sample 608 caption data into a file with SpliceFragmented, and go608-info / go608-extract reuse the read side (locating the video track, flattening fragments into samples, and pulling the field pairs back out).
Package mp4io holds the shared fragmented-mp4 muxing glue used by the go-608 commands: go608-clock and go608-inject splice per-sample 608 caption data into a file with SpliceFragmented, and go608-info / go608-extract reuse the read side (locating the video track, flattening fragments into samples, and pulling the field pairs back out).
Package scc reads and writes Scenarist SCC files — a byte-pair container for CTA-608 caption data, a sibling of the SEI carriage.
Package scc reads and writes Scenarist SCC files — a byte-pair container for CTA-608 caption data, a sibling of the SEI carriage.
Package schedule maps wall-time-tagged CTA-608 token transitions onto individual video frames, emitting the primitive {Field1, Field2, CCCount} triple that the carriage package consumes.
Package schedule maps wall-time-tagged CTA-608 token transitions onto individual video frames, emitting the primitive {Field1, Field2, CCCount} triple that the carriage package consumes.
Package srt is a thin SRT serializer over the cue model: it implements cue.Reader and cue.Writer and is the simpler sibling of the webvtt package.
Package srt is a thin SRT serializer over the cue model: it implements cue.Reader and cue.Writer and is the simpler sibling of the webvtt package.
Package webvtt is a thin WebVTT serializer over the cue model: it implements cue.Reader and cue.Writer and owns only WebVTT syntax and its styling/positioning quantization.
Package webvtt is a thin WebVTT serializer over the cue model: it implements cue.Reader and cue.Writer and owns only WebVTT syntax and its styling/positioning quantization.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL