Ofcom subtitle timing requirements explained

UK broadcast delivery quantises every subtitle cue to a 25 fps grid, which fixes a 40 ms frame boundary as the smallest legal time step. The exact failure this page solves is the QC rejection that fires when a theoretically valid SRT or WebVTT file carries timestamps that do not sit on that 40 ms boundary — the residue of IEEE 754 double-precision export from an ASR engine or a datetime.timedelta round-trip. Once cues are off-grid, the Ofcom Code on Subtitling Standards thresholds (minimum on-screen dwell of 1.0 s / 25 frames, maximum dwell of 7.0 s / 175 frames, and a minimum inter-cue gap of 250 ms) can no longer be enforced deterministically: a block that measures 1.000 s in floating point can render as 24 frames after the decoder truncates, breaching the floor by one frame. The fix is to abandon float seconds entirely and operate on integer frame counts.

from dataclasses import dataclass
from typing import Iterator, List, Tuple

# UK 25 fps PAL delivery grid — EBU N19 / Ofcom Code on Television Access Services
FPS            = 25
FRAME_MS       = 40     # 1000 ms / 25 fps — the only legal time step
MIN_DUR_FRAMES = 25     # Ofcom: minimum 1.0 s on-screen dwell  (25 frames)
MAX_DUR_FRAMES = 175    # Ofcom: maximum 7.0 s on-screen dwell  (175 frames)
MIN_GAP_FRAMES = 7      # Ofcom: >=250 ms inter-cue gap; 6.25 -> round up to 7 (280 ms)

@dataclass(frozen=True)
class SubtitleCue:
    index: int
    start_ms: int
    end_ms: int
    text: str

def to_frames(ms: int) -> int:
    """Snap a millisecond value to the nearest 40 ms grid, return integer frames."""
    return round(ms / FRAME_MS)            # nearest-frame rounding kills float drift

def to_ms(frames: int) -> int:
    return frames * FRAME_MS               # exact: every value lands on the grid

def quantize_and_validate(
    cues: List[SubtitleCue],
) -> Iterator[Tuple[SubtitleCue, str]]:
    """Yield (grid-aligned cue, flag). All maths is integer-frame, never float seconds."""
    prev_end = 0                           # running end position, in frames
    for cue in cues:
        start = to_frames(cue.start_ms)
        end   = to_frames(cue.end_ms)
        flags: List[str] = []

        if (end - start) < MIN_DUR_FRAMES:           # Ofcom 1.0 s floor
            end = start + MIN_DUR_FRAMES
            flags.append("MIN_DWELL_EXTENDED")
        if (end - start) > MAX_DUR_FRAMES:           # Ofcom 7.0 s ceiling -> force split
            end = start + MAX_DUR_FRAMES
            flags.append("MAX_DWELL_CLAMPED")
        if prev_end and (start - prev_end) < MIN_GAP_FRAMES:   # Ofcom inter-cue gap
            start = prev_end + MIN_GAP_FRAMES
            if (end - start) < MIN_DUR_FRAMES:       # re-assert dwell after the shove
                end = start + MIN_DUR_FRAMES
            flags.append("GAP_ENFORCED")
        if abs(to_ms(start) - cue.start_ms) >= FRAME_MS:       # was off-grid on input
            flags.append("OFFGRID_CORRECTED")

        prev_end = end
        yield SubtitleCue(cue.index, to_ms(start), to_ms(end), cue.text), \
            "|".join(flags) or "COMPLIANT"

Code walkthrough

to_frames is the entire defence against drift. Dividing the input millisecond value by FRAME_MS (40) and applying round collapses any sub-frame residue — a 1003 ms ASR timestamp and a 997 ms one both resolve to frame 25 — so two pipelines that disagree at the microsecond level still emit byte-identical grid output. Reconstruction via to_ms multiplies back by 40, which guarantees every exported timestamp is an exact multiple of the frame interval; nothing downstream can be off-grid because off-grid values are unrepresentable in the integer domain.

The three threshold blocks enforce the Ofcom Code in dependency order. Minimum dwell is checked first because both later corrections can shorten a cue: a cue under 25 frames is stretched at its tail to exactly 1.0 s. Maximum dwell clamps any cue over 175 frames to the 7.0 s ceiling and raises MAX_DWELL_CLAMPED so a downstream reflow step knows the block is a split candidate rather than silently truncated. The gap check is last because it is the only rule that mutates start: Ofcom requires a clean separation between consecutive cues so the decoder can flush its render buffer, and 250 ms is 6.25 frames, which is not an integer — rounding up to 7 frames (280 ms) is the only way to stay at or above the floor while landing on the grid. After shoving start forward, dwell is re-asserted so the gap correction can never produce a sub-1.0 s block.

prev_end carries state in frames, never milliseconds, so the inter-cue arithmetic (start - prev_end) is exact integer subtraction with no accumulated error across a multi-hour asset. The OFFGRID_CORRECTED flag is diagnostic: it fires whenever the input start differed from the grid-snapped value by a frame or more, which is the signature of upstream float export and the field you grep for when triaging where drift entered the pipeline. The generator yields one cue at a time, so memory is bounded by a single cue regardless of asset length — a multi-hour VOD manifest streams through with O(1) overhead.

Quantising subtitle timestamps to the Ofcom 25 fps grid and enforcing dwell and gap The top half shows a horizontal timeline marked every 40 ms at frame boundaries 23 through 28 (920 to 1120 ms). Two off-grid floating-point inputs, 988 ms and 1013 ms, both snap with arrows onto frame 25 at exactly 1000 ms via nearest-frame rounding. The bottom half shows two adjacent cue blocks on the same integer-frame grid: a dwell measure spanning the first cue labelled with the Ofcom minimum of 1.0 second (25 frames) and maximum of 7.0 seconds (175 frames), and a separating gap labelled at least 7 frames (280 ms). A footer notes the violation flags raised when these limits are corrected. Snap to the 40 ms / 25 fps grid, then enforce the Ofcom limits to_frames(ms) = round(ms / 40) — float residue collapses onto the nearest frame, off-grid values become unrepresentable off-grid ASR float input 988 ms 1013 ms f23920 ms f24960 ms f261040 ms f271080 ms f281120 ms f25 1000 ms · exact Enforce dwell and gap on the snapped integer frames Cue n on screen Cue n+1 on screen dwell 1.0 s (25f) ≤ d ≤ 7.0 s (175f) gap ≥ 7f (280 ms) corrections raise MIN_DWELL_EXTENDED · MAX_DWELL_CLAMPED · GAP_ENFORCED · OFFGRID_CORRECTED

Edge cases & known gotchas

  • Non-25 fps sources. A 23.976 or 29.97 fps caption file snapped to the 25 fps grid will look aligned but be systematically offset — convert and re-base timecodes before quantising, via SRT timestamp normalization, or the OFFGRID_CORRECTED flag will fire on every cue.
  • Cascade overflow. Enforcing the gap on a dense dialogue run pushes each start forward, which can cascade and drift the tail of a sequence later than its audio; cap the cumulative shove and flag the block for a manual reflow rather than letting it walk.
  • Maximum-dwell clamp loses text. MAX_DWELL_CLAMPED only fixes the timing; the on-screen text still needs splitting into two blocks. Treat the flag as a work item, not a resolution.
  • round() banker’s rounding. Python’s round uses round-half-to-even, so a value exactly on a half-frame (e.g. 20 ms) snaps to the nearest even frame. This is deterministic and fine for QC, but document it — a naive reviewer expecting round-half-up will see a one-frame difference.
  • Legitimate long cues. Lyric and karaoke assets routinely exceed 7.0 s by design; gate MAX_DWELL_CLAMPED behind a per-genre override instead of hard-clamping every block.

Integration hook

This quantiser is the calibration stage that runs immediately after parse and before the rule engine in the Ofcom Code on Subtitling Standards validator: that cluster’s validate_ofcom step assumes its input is already on the 40 ms grid when it computes reading speed and dwell, so this snap must run first. Feed its grid-aligned SubtitleCue stream straight into that validator, and route the emitted flags into the same JSON verdict so an OFFGRID_CORRECTED count becomes a tracked QC metric rather than a silent repair.

Part of: Broadcast Captioning Architecture & Compliance — the regulatory-thresholds-as-code reference for broadcast and OTT caption pipelines.