#!/usr/bin/env python3 """Independent verifier of the timed-text corpus (extensions/timed-text.md). Written from the extension's text. It shares no code with wai-rs, which generates the corpus, and never runs it. It reads and verifies every event record with live_events_verify.py (this directory), the second implementation of live-events, and writes everything else from the extension's sections: - the cue model and its subset (§3); - revisions and their annotation (§4.2, §4.3), what a sink shows (§4.4), the cues a stream resolves to (§4.5), and the join set of the events track (§4.6); - WebVTT documents, segments and the reader of the subset (§5); - IMSC1 documents (§6.1), the check of the hypothetical render model (§6.2), and the structure of every document checked with xml.etree (§6.3); - the MoQ caption timelines and their catalog entries (§7); - CTA-608: the character tables, the layout, pop-on and roll-up, the byte pairs, and a decoder (§8); - the disclosure label: HLS tags, C2PA statement, sidecar (§10). For every vector it requires the outcome, reason, index, revisions, what is shown, the cues and every byte of every output the corpus states; that every reason of §12 and every 608 code path of §8 appears; and that every mutated copy of the corpus fails. python3 timed_text_verify.py Exit status 0 only if every case verifies. Python 3.8 or later; standard library only. """ import copy import json import os import sys import xml.etree.ElementTree as ET HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import live_events_verify as LEV # noqa: E402 # §12, in its order. REASONS = [ "time_out_of_range", "cue_malformed", "model_missing", "text_not_in_subset", "language_mismatch", "annotation_malformed", "annotation_misplaced", "supersede_unknown", "supersede_final", "supersede_forked", "supersede_mismatch", "stability_broken", "not_final", "duration_unknown", "vtt_malformed", "hrm_exceeded", "parameter_malformed", "cues_overlap", "too_many_rows", "row_too_long", "char_not_in_608", "style_not_expressible", "too_dense", "cc_malformed", ] LAST_MS = 359999999 ORIGINS = {1: "human", 2: "machine", 3: "machine-reviewed"} ANCHORS = ["bottom-center", "bottom-left", "bottom-right", "top-center", "top-left", "top-right"] NS_WAI = "tag:transaction.science,2026:wai/timed-text" NS_TT = "http://www.w3.org/ns/ttml" NS_TTS = "http://www.w3.org/ns/ttml#styling" NS_TTP = "http://www.w3.org/ns/ttml#parameter" NS_XML = "http://www.w3.org/XML/1998/namespace" NS_EBUTTM = "urn:ebu:tt:metadata" PROFILES = ["http://www.w3.org/ns/ttml/profile/imsc1/text", "http://www.w3.org/ns/ttml/profile/imsc1.3/text"] DST = "http://cv.iptc.org/newscodes/digitalsourcetype/" MODEL_URI = "tag:transaction.science,2026:wai/model/" CAPTION, META = 0x0A, 0x01 GROUP_US = 60000000 # live-events §8.1 # live-events §5's default policy: every kind of §4 but the lifecycle ones. _kinds = bytearray(32) for _k in range(0x01, 0x0D): _kinds[_k // 8] |= 1 << (_k % 8) DEFAULT_POLICY = {"kinds": bytes(_kinds), "max_payload": 1024, "max_text_cp": 500, "max_events": 50, "window_us": 1000000, "max_reactions": 1, "reaction_window_us": 1000000} class Refused(Exception): def __init__(self, reason): super().__init__(reason) self.reason = reason # ---------------------------------------------------------------- §3 the cue model def align(anchor): return anchor.split("-")[1] def is_top(anchor): return anchor.startswith("top") def text_ok(s): """§3.2 rules 1 to 3.""" if s == "": return False for line in s.split("\n"): if line == "" or line[0] == " " or line[-1] == " " or " " in line: return False for ch in line: v = ord(ch) inside = 0x20 <= v <= 0x7E or 0xA0 <= v <= 0xD7FF or 0xE000 <= v <= 0xFFFD or 0x10000 <= v <= 0x10FFFF if not inside or v in (0x2028, 0x2029, 0xFEFF): return False return True def ranges_ok(s, ranges): """§3.2 rule 4.""" end_before = None for start, n in ranges: if n < 1 or start + n > len(s): return False if end_before is not None: if start <= end_before or set(s[end_before:start]) <= {" "}: return False piece = s[start:start + n] if "\n" in piece or piece[0] == " " or piece[-1] == " ": return False end_before = start + n return True def lang_ok(tag): return isinstance(tag, str) and 2 <= len(tag) <= 35 and LEV.LANG.fullmatch(tag) is not None def check_cue(c): """§3.4, in its order.""" if not (c["start"] < c["end"] <= LAST_MS): raise Refused("time_out_of_range") if not lang_ok(c["lang"]) or c["origin"] not in ORIGINS.values(): raise Refused("cue_malformed") if c["origin"] == "human" and c["model"] is not None: raise Refused("cue_malformed") if c["origin"] != "human" and c["model"] is None: raise Refused("model_missing") if not text_ok(c["text"]): raise Refused("text_not_in_subset") if not ranges_ok(c["text"], c["italic"]): raise Refused("cue_malformed") def check_doc(lang, cues): if not lang_ok(lang): raise Refused("parameter_malformed") for c in cues: check_cue(c) if any(c["lang"] != lang for c in cues): raise Refused("language_mismatch") def in_order(cues): return sorted(cues, key=lambda c: c["start"]) # stable: equal starts keep their order def italic_flags(c): flags = [False] * len(c["text"]) for start, n in c["italic"]: for i in range(start, start + n): flags[i] = True return flags def runs_of(c): """Each line of the cue, as [(italic, text)].""" flags = italic_flags(c) out, line = [], [] for i, ch in enumerate(c["text"]): if ch == "\n": out.append(line) line = [] elif line and line[-1][0] == flags[i]: line[-1] = (flags[i], line[-1][1] + ch) else: line.append((flags[i], ch)) out.append(line) return out # ---------------------------------------------------------------- §5 WebVTT def hhmmss(ms): return "%02d:%02d:%02d.%03d" % (ms // 3600000, ms // 60000 % 60, ms // 1000 % 60, ms % 1000) def settings(anchor): line = "line:10%,start" if is_top(anchor) else "line:90%,end" return "%s position:50%%,center size:80%% align:%s" % (line, align(anchor)) def esc(s): return s.replace("&", "&").replace("<", "<").replace(">", ">") def label_class(c): return {"machine": "wai-machine", "machine-reviewed": "wai-machine-reviewed"}.get(c["origin"]) def vtt_payload(c): lines = ["".join("%s" % esc(t) if it else esc(t) for it, t in line) for line in runs_of(c)] body = "\n".join(lines) cls = label_class(c) if cls: body = "%s" % (cls, c["model"], body) return body def vtt_cue(c): return "%s --> %s %s\n%s\n" % (hhmmss(c["start"]), hhmmss(c["end"]), settings(c["anchor"]), vtt_payload(c)) def webvtt(lang, cues): check_doc(lang, cues) return "WEBVTT\n\n" + "\n".join(vtt_cue(c) for c in in_order(cues)) def webvtt_segments(lang, cues, bounds, mpegts0): check_doc(lang, cues) if len(bounds) < 2 or any(a >= b for a, b in zip(bounds, bounds[1:])) or bounds[-1] > LAST_MS or mpegts0 >= 2 ** 33: raise Refused("parameter_malformed") out = [] for b, e in zip(bounds, bounds[1:]): body = "\n".join(vtt_cue(c) for c in in_order(cues) if c["start"] < e and c["end"] > b) out.append("WEBVTT\nX-TIMESTAMP-MAP=LOCAL:%s,MPEGTS:%d\n\n%s" % (hhmmss(b), (mpegts0 + 90 * b) % 2 ** 33, body)) return out def read_hhmmss(s): if len(s) != 12 or s[2] != ":" or s[5] != ":" or s[8] != "." or not (s[:2] + s[3:5] + s[6:8] + s[9:]).isdigit() or not s.isascii(): raise Refused("vtt_malformed") h, m, sec, ms = int(s[:2]), int(s[3:5]), int(s[6:8]), int(s[9:]) if m > 59 or sec > 59: raise Refused("vtt_malformed") return ((h * 60 + m) * 60 + sec) * 1000 + ms def unesc(s): out, i = [], 0 while i < len(s): if s[i] == "&": for ref, ch in (("&", "&"), ("<", "<"), (">", ">")): if s.startswith(ref, i): out.append(ch) i += len(ref) break else: raise Refused("vtt_malformed") elif s[i] in "<>": raise Refused("vtt_malformed") else: out.append(s[i]) i += 1 return "".join(out) def read_webvtt(doc, lang): """§5.3: (cues, (local, mpegts) or None).""" bad = Refused("vtt_malformed") if not doc.startswith("WEBVTT\n"): raise bad rest = doc[7:] tsmap = None if rest.startswith("X-TIMESTAMP-MAP=LOCAL:"): line, nl, rest = rest[22:].partition("\n") local, sep, mp = line.partition(",MPEGTS:") if not nl or not sep or not mp.isdigit() or not mp.isascii() or (len(mp) > 1 and mp[0] == "0") or int(mp) >= 2 ** 33: raise bad tsmap = (read_hhmmss(local), int(mp)) if not rest.startswith("\n"): raise bad rest = rest[1:] cues = [] blocks = rest.split("\n\n") if rest else [] for k, block in enumerate(blocks): if not block.endswith("\n") and k == len(blocks) - 1: raise bad lines = block.split("\n") if k == len(blocks) - 1: if lines[-1] != "": raise bad lines = lines[:-1] if len(lines) < 2 or any(x == "" for x in lines): raise bad head = lines[0].split(" ", 3) if len(head) != 4 or head[1] != "-->": raise bad start, end = read_hhmmss(head[0]), read_hhmmss(head[2]) anchor = next((a for a in ANCHORS if settings(a) == head[3]), None) if anchor is None: raise bad body = "\n".join(lines[1:]) origin, model = "human", None if body.startswith("") if not gt or not inner.endswith(""): raise bad classes = tag.split(".") origin = {"wai-machine": "machine", "wai-machine-reviewed": "machine-reviewed"}.get(classes[0]) if origin is None: raise bad if len(classes) == 1: raise Refused("model_missing") mc = classes[1] if len(classes) != 2 or not mc.startswith("wai-model-") or len(mc) != 74 or any(ch not in "0123456789abcdef" for ch in mc[10:]): raise bad model, body = mc[10:], inner[:-4] text, italic, pos = [], [], 0 for n, line in enumerate(body.split("\n")): if n: text.append("\n") pos += 1 runs, rest_line = [], line while rest_line: if rest_line.startswith(""): inner, sep, after = rest_line[3:].partition("") if not sep or inner == "" or "<" in inner: raise bad runs.append((True, unesc(inner))) rest_line = after else: cut = rest_line.find("<") cut = len(rest_line) if cut < 0 else cut if cut == 0: raise bad runs.append((False, unesc(rest_line[:cut]))) rest_line = rest_line[cut:] if any(a[0] == b[0] for a, b in zip(runs, runs[1:])): raise bad for it, t in runs: if it: if italic and italic[-1][0] + italic[-1][1] == pos: italic[-1][1] += len(t) else: italic.append([pos, len(t)]) text.append(t) pos += len(t) cue = {"start": start, "end": end, "lang": lang, "origin": origin, "model": model, "anchor": anchor, "text": "".join(text), "italic": italic} if cues and cues[-1]["start"] > start: raise bad check_cue(cue) cues.append(cue) return cues, tsmap # ---------------------------------------------------------------- §6 IMSC1 REGION_LINES = [ '', '', ] def region(anchor): return "top" if is_top(anchor) else "bottom" def imsc_p(c): attrs = 'begin="%s" end="%s" region="%s" tts:textAlign="%s"' % (hhmmss(c["start"]), hhmmss(c["end"]), region(c["anchor"]), align(c["anchor"])) if c["origin"] != "human": attrs += ' wai:origin="%s" wai:model="%s"' % (c["origin"], c["model"]) lines = ["".join('%s' % esc(t) if it else esc(t) for it, t in line) for line in runs_of(c)] return "

%s

" % (attrs, "
".join(lines)) def imsc(lang, cues): check_doc(lang, cues) cues = in_order(cues) hrm(cues) out = ['', '' % (NS_TT, NS_TTP, NS_TTS, NS_EBUTTM, NS_WAI, lang, PROFILES[0]), "", "", ""] out += ["%s" % p for p in PROFILES] out += ["", "", ""] + REGION_LINES + ["", "", "", "
"] out += [imsc_p(c) for c in cues] out += ["
", "", "
"] return "\n".join(out) + "\n" def hrm(cues): """§6.2, in units of 1/2700 s.""" times = sorted(set([c["start"] for c in cues] + [c["end"] for c in cues])) cache = {} # glyph -> flagged last = None for at, _ in zip(times, times[1:]): live = [c for c in cues if c["start"] <= at < c["end"]] if not live: cache = {} continue budget_ms = at - last if last is not None and at - last < 1000 else 1000 cost = 225 for reg in ("top", "bottom"): for c in live: if region(c["anchor"]) != reg: continue flags = italic_flags(c) for i, ch in enumerate(c["text"]): if ch == "\n": continue g = (ch, flags[i]) cost += 4 if g in cache else 20 cache[g] = True if sum(1 for f in cache.values() if f) > 225: raise Refused("hrm_exceeded") if 27 * budget_ms < 10 * cost: raise Refused("hrm_exceeded") cache = {g: False for g, f in cache.items() if f} last = at def percents(s): """Two lengths in percent, as numbers; None otherwise.""" parts = s.split(" ") if len(parts) != 2 or not all(v.endswith("%") and v[:-1].replace(".", "", 1).isdigit() and v.isascii() for v in parts): return None return tuple(float(v[:-1]) for v in parts) def clock(s): return read_hhmmss(s) def imsc_structure(doc, cues): """§6.3, with xml.etree: the failures.""" fails = [] if not doc.startswith('\n'): fails.append("no UTF-8 XML declaration") try: root = ET.fromstring(doc.encode("utf-8")) except ET.ParseError as e: return ["not well-formed: %s" % e] tt = lambda n: "{%s}%s" % (NS_TT, n) # noqa: E731 if root.tag != tt("tt") or not lang_ok(root.get("{%s}lang" % NS_XML)): fails.append("the root is not tt with xml:lang") named = [e.text for e in root.iter("{%s}conformsToStandard" % NS_EBUTTM)] if named != PROFILES: fails.append("the profiles named are %s" % named) # IMSC 1.0.1 §6.9: ttp:profile on tt, the IMSC 1.0 Text Profile's designator; no # ttp:profile element; never an Image Profile designator beside a Text Profile one. if root.get("{%s}profile" % NS_TTP) != PROFILES[0]: fails.append("tt's ttp:profile is %s" % root.get("{%s}profile" % NS_TTP)) if root.find(".//{%s}profile" % NS_TTP) is not None: fails.append("a ttp:profile element is present") if any("/image" in (d or "") for d in named + [root.get("{%s}profile" % NS_TTP)]): fails.append("an Image Profile designator is signalled") regions = {} for r in root.iter(tt("region")): rid = r.get("{%s}id" % NS_XML) o, x = r.get("{%s}origin" % NS_TTS, ""), r.get("{%s}extent" % NS_TTS, "") if r.get("{%s}position" % NS_TTS) is not None: fails.append("region %s has tts:position" % rid) po, px = percents(o), percents(x) if po is None or px is None: fails.append("region %s is not in percent" % rid) continue (ox, oy), (ex, ey) = po, px if ox + ex > 100 or oy + ey > 100: fails.append("region %s leaves the root container" % rid) regions[rid] = (ox, oy, ox + ex, oy + ey) if sorted(regions) != ["bottom", "top"]: fails.append("the regions are %s" % sorted(regions)) rs = list(regions.values()) for i in range(len(rs)): for j in range(i + 1, len(rs)): a, b = rs[i], rs[j] if a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]: fails.append("two regions overlap") allowed_tt = {"begin", "end", "region", "{%s}textAlign" % NS_TTS, "{%s}fontStyle" % NS_TTS} body = root.find(tt("body")) divs = [] if body is None else list(body) if len(divs) != 1 or divs[0].tag != tt("div"): fails.append("the body is not one div") return fails ps = list(divs[0]) if len(ps) != len(cues): fails.append("%d p for %d cues" % (len(ps), len(cues))) for p, c in zip(ps, in_order(cues)): if p.tag != tt("p"): fails.append("a child of div is not p") continue try: b, e = clock(p.get("begin", "")), clock(p.get("end", "")) if not b < e: fails.append("a p does not begin before it ends") except Refused: fails.append("a p's times are not clock times") if p.get("region") not in regions: fails.append("a p names no region") for k in p.attrib: if k.startswith("{%s}" % NS_WAI): continue if k not in allowed_tt: fails.append("p has attribute %s" % k) has_label = (p.get("{%s}origin" % NS_WAI), p.get("{%s}model" % NS_WAI)) want = (c["origin"], c["model"]) if c["origin"] != "human" else (None, None) if has_label != want: fails.append("the label of a p is %s, its cue's %s" % (has_label, want)) for child in p.iter(): if child is p: continue if child.tag == tt("br"): if child.attrib or (child.text or ""): fails.append("a br has content") elif child.tag == tt("span"): if dict(child.attrib) != {"{%s}fontStyle" % NS_TTS: "italic"} or len(child): fails.append("a span is not one italic run") else: fails.append("p holds %s" % child.tag) if "".join(p.itertext()).replace("\n", "") != c["text"].replace("\n", ""): fails.append("a p's text is not its cue's") return fails # ---------------------------------------------------------------- §7 timelines def jstr(s): out = ['"'] for ch in s: if ch == '"': out.append('\\"') elif ch == "\\": out.append("\\\\") elif ch == "\n": out.append("\\n") elif ord(ch) < 0x20: out.append("\\u%04x" % ord(ch)) else: out.append(ch) out.append('"') return "".join(out) def vtt_record(c): line = "10%,start" if is_top(c["anchor"]) else "90%,end" return ('{"m":%d,"data":{"start":%d,"end":%d,"text":%s,"settings":{"line":"%s","position":"50%%,center","size":"80%%","align":"%s"}}}' % (c["start"], c["start"], c["end"], jstr(vtt_payload(c)), line, align(c["anchor"]))) IMSC_INIT = ('{"m":0,"data":{"init":{"profile":"http://www.w3.org/ns/ttml/profile/imsc1/text",' '"styles":{"center":{"textAlign":"center"},"left":{"textAlign":"left"},"right":{"textAlign":"right"}},' '"regions":{"bottom":{"origin":"10% 55%","extent":"80% 35%","displayAlign":"after"},' '"top":{"origin":"10% 10%","extent":"80% 35%","displayAlign":"before"}}}}}') def imsc_record(c): content = [] for n, line in enumerate(runs_of(c)): if n: content.append('{"br":true}') content += ['{"text":%s,"fontStyle":"italic"}' % jstr(t) if it else '{"text":%s}' % jstr(t) for it, t in line] data = '"start":%d,"end":%d,"region":"%s","style":"%s","content":[%s]' % (c["start"], c["end"], region(c["anchor"]), align(c["anchor"]), ",".join(content)) if c["origin"] != "human": data += ',"wai_origin":"%s","wai_model":"%s"' % (c["origin"], c["model"]) return '{"m":%d,"data":{%s}}' % (c["start"], data) def timeline(kind, lang, published, group_ms, first, last): check_doc(lang, [c for c, _ in published]) if group_ms < 1 or first > last or any(t < c["start"] for c, t in published): raise Refused("parameter_malformed") order = sorted(published, key=lambda ct: ct[1]) rec = vtt_record if kind == "webvtt" else imsc_record groups = [] for g in range(first, last + 1): lo, hi = g * group_ms, (g + 1) * group_ms head = [IMSC_INIT] if kind == "imsc" else [] head += [rec(c) for c, t in order if t < lo and c["end"] > lo] objects = ["[" + ",".join(head) + "]"] + ["[" + rec(c) + "]" for c, t in order if lo <= t < hi] groups.append({"group": g, "objects": objects}) return groups def catalog(kind, name, lang, depends, cues): et, mode = ("urn:msf:captions:webvtt", "") if kind == "webvtt" else ("urn:msf:captions:imsc1", ',"imsc1Mode":"cue"') mg = "true" if any(c["origin"] != "human" for c in cues) else "false" return ('{"name":%s,"packaging":"eventtimeline","eventType":"%s","mimeType":"application/json","depends":[%s],"role":"caption","lang":%s%s,"wai_machine_generated":%s}' % (jstr(name), et, ",".join(jstr(d) for d in depends), jstr(lang), mode, mg)) # ---------------------------------------------------------------- §10 the label def attr_ok(s): return s != "" and all(ch.isascii() and (ch.isalnum() or ch in " ._-/:") for ch in s) def media_tag(kind, group, name, lang, chars, cues, uri=None): check_doc(lang, cues) if not attr_ok(group) or not attr_ok(name) or (uri is not None and not attr_ok(uri)) or any(not attr_ok(c) or "," in c or c == "public.machine-generated" for c in chars): raise Refused("parameter_malformed") cs = list(chars) + (["public.machine-generated"] if any(c["origin"] != "human" for c in cues) else []) tag = '#EXT-X-MEDIA:TYPE=%s,GROUP-ID="%s",NAME="%s",LANGUAGE="%s"' % (kind, group, name, lang) if kind == "CLOSED-CAPTIONS": tag += ',INSTREAM-ID="CC1"' if cs: tag += ',CHARACTERISTICS="%s"' % ",".join(cs) if uri is not None: tag += ',URI="%s"' % uri return tag def source_type(cues): kinds = sorted(set(c["origin"] for c in cues)) if kinds in ([], ["human"]): return DST + "digitalCreation" if kinds == ["machine"]: return DST + "trainedAlgorithmicMedia" if kinds == ["machine-reviewed"]: return DST + "compositeWithTrainedAlgorithmicMedia" return DST + "compositeSynthetic" def c2pa(cues): models = [] # [model, any machine] for c in in_order(cues): if c["model"] is None: continue hit = next((m for m in models if m[0] == c["model"]), None) if hit is None: models.append([c["model"], c["origin"] == "machine"]) else: hit[1] = hit[1] or c["origin"] == "machine" parts = [] for i, (m, auto) in enumerate(models): label = "c2pa.ai-disclosure" if i == 0 else "c2pa.ai-disclosure__%d" % i parts.append('{"label":"%s","data":{"modelType":"c2pa.types.model","modelIdentifier":"%s%s","contentProfile":{"humanOversightLevel":"%s"}}}' % (label, MODEL_URI, m, "fully_autonomous" if auto else "human_validated")) return '{"digitalSourceType":"%s","assertions":[%s]}' % (source_type(cues), ",".join(parts)) def sidecar(lang, cues): check_doc(lang, cues) rows = [] for c in in_order(cues): m = ',"model":"%s"' % c["model"] if c["model"] is not None else "" rows.append('{"start":%d,"end":%d,"origin":"%s"%s}' % (c["start"], c["end"], c["origin"], m)) return '{"wai_timed_text":1,"lang":%s,"cues":[%s]}' % (jstr(lang), ",".join(rows)) # ---------------------------------------------------------------- §8 CTA-608 SPECIAL = "®°½¿™¢£♪à èâêîôû" EXT = { 0x12: "ÁÉÓÚÜü‘¡*\x00—©℠•“”ÀÂÇÈÊËëÎÏïÔÙùÛ«»", 0x13: "ÃãÍÌìÒòÕõ{}\\^_|~ÄäÖöߥ¤¦Å娸┌┐└┘", } SUBST = {"’": "'", "–": "-", "‐": "-", "‑": "-"} BASIC_SWAP = {0x2A: "á", 0x5C: "é", 0x5E: "í", 0x5F: "ó", 0x60: "ú", 0x7B: "ç", 0x7C: "÷", 0x7D: "Ñ", 0x7E: "ñ"} PAC_FIRST = {1: 0x11, 2: 0x11, 3: 0x12, 4: 0x12, 5: 0x15, 6: 0x15, 7: 0x16, 8: 0x16, 9: 0x17, 10: 0x17, 11: 0x10, 12: 0x13, 13: 0x13, 14: 0x14, 15: 0x14} FALLBACK = {} for _letters, _base in (("ÁÀÂÃÄÅ", "A"), ("ÉÈÊË", "E"), ("ÍÌÎÏ", "I"), ("ÓÒÔÕÖØ", "O"), ("ÚÙÛÜ", "U"), ("Ç", "C"), ("ãäå", "a"), ("ë", "e"), ("ìï", "i"), ("òõöø", "o"), ("ùü", "u"), ("ß", "s"), ("“”«»", '"'), ("‘", "'"), ("¡", "!")): for _ch in _letters: FALLBACK[_ch] = _base def basic(b): if b in BASIC_SWAP: return BASIC_SWAP[b] if 0x20 <= b <= 0x7A: return chr(b) return None BASIC_OF = {basic(b): b for b in range(0x20, 0x7F) if basic(b) is not None} def send(ch): """§8.4: (shown as, how sent) or None.""" ch = SUBST.get(ch, ch) if ch in BASIC_OF: return ch, ("b", BASIC_OF[ch]) if ch in SPECIAL: return ch, ("s", 0x30 + SPECIAL.index(ch)) for first in (0x12, 0x13): table = EXT[first] if ch in table and ch != "\x00": return ch, ("x", first, 0x20 + table.index(ch), ord(FALLBACK.get(ch, " "))) return None def row_base(row): return 0x60 if row in (2, 4, 6, 8, 10, 13, 15) else 0x40 def odd(b): return b | 0x80 if bin(b & 0x7F).count("1") % 2 == 0 else b & 0x7F def layout(mode, c): """§8.5: [(row, col, text, italic ranges)], or Refused.""" lines = runs_of(c) rows_max = 4 if mode == "pop-on" else int(mode[-1]) if len(lines) > rows_max: raise Refused("too_many_rows") shown = [] for line in lines: text, flags = "", [] for it, t in line: for ch in t: s = send(ch) if s is None: raise Refused("char_not_in_608") text += s[0] flags.append(it) shown.append((text, flags)) out = [] for k, (text, flags) in enumerate(shown): width = len(text) if width > 32: raise Refused("row_too_long") ranges = [] for i, f in enumerate(flags): if f: if ranges and ranges[-1][0] + ranges[-1][1] == i: ranges[-1][1] += 1 else: ranges.append([i, 1]) for s, n in ranges: if (s > 0 and text[s - 1] != " ") or (s + n < width and text[s + n] != " "): raise Refused("style_not_expressible") n = len(shown) if mode == "pop-on": row = k + 1 if is_top(c["anchor"]) else 16 - n + k else: row = int(mode[-1]) if is_top(c["anchor"]) else 15 a = align(c["anchor"]) col = 0 if a == "left" else (32 - width if a == "right" else (32 - width) // 2) out.append((row, col, text, ranges)) return out def row_codes(row, col, text, ranges): """§8.5: [("b", byte) | ("2", b1, b2)].""" flags = [False] * len(text) for s, n in ranges: for i in range(s, s + n): flags[i] = True def place(x): out = [("2", PAC_FIRST[row], row_base(row) + 0x10 + 2 * (x // 4))] if x % 4: out.append(("2", 0x17, 0x20 + x % 4)) return out if flags and flags[0] and col == 0: codes = [("2", PAC_FIRST[row], row_base(row) + 0x0E)] elif flags and flags[0]: codes = place(col - 1) + [("2", 0x11, 0x2E)] else: codes = place(col) for i, ch in enumerate(text): if ch == " " and not flags[i] and i + 1 < len(text) and flags[i + 1]: codes.append(("2", 0x11, 0x2E)) elif ch == " " and not flags[i] and i > 0 and flags[i - 1]: codes.append(("2", 0x11, 0x20)) else: how = send(ch)[1] if how[0] == "b": codes.append(("b", how[1])) elif how[0] == "s": codes.append(("2", 0x11, how[1])) else: codes.append(("b", how[3])) codes.append(("2", how[1], how[2])) return codes def units_of(codes): """§8.3: [(pair, slots)] — one slot for a pair of standard bytes, two for a doubled code.""" out, held = [], None for c in codes: if c[0] == "b": if held is None: held = c[1] else: out.append(((held, c[1]), 1)) held = None else: if held is not None: out.append(((held, 0), 1)) held = None out.append(((c[1], c[2]), 2)) if held is not None: out.append(((held, 0), 1)) return out def slot_of(ms, rate): num, den = rate return -((-ms * num) // (1000 * den)) def encode608(mode, rate, lang, cues): """§8.6, §8.7: {slot: (b1, b2)} with parity.""" check_doc(lang, cues) num, den = rate if not (0 < num <= 1000000 and 0 < den <= 1000000) or mode not in ("pop-on", "roll-up-2", "roll-up-3", "roll-up-4"): raise Refused("parameter_malformed") cues = in_order(cues) rows = [layout(mode, c) for c in cues] for a, b in zip(cues, cues[1:]): if b["start"] < a["end"]: raise Refused("cues_overlap") if mode != "pop-on" and len(set(is_top(c["anchor"]) for c in cues)) > 1: raise Refused("style_not_expressible") s = [slot_of(c["start"], rate) for c in cues] e = [slot_of(c["end"], rate) for c in cues] n = len(cues) erase = [i == n - 1 or s[i + 1] >= e[i] + 2 for i in range(n)] slots = {} def put(at, pair, count): for k in range(count): slots[at + k] = (odd(pair[0]), odd(pair[1])) if mode == "pop-on": for i in range(n): if e[i] < s[i] + 2: raise Refused("too_dense") put(s[i], (0x14, 0x2F), 2) if erase[i]: put(e[i], (0x14, 0x2C), 2) for i in range(n): codes = [("2", 0x14, 0x20), ("2", 0x14, 0x2E)] for r in rows[i]: codes += row_codes(*r) floor = 0 if i == 0 else s[i - 1] + 2 ceiling = s[i] for pair, count in reversed(units_of(codes)): q = ceiling - count while q >= floor and any(q + k in slots for k in range(count)): q -= 1 if q < floor: raise Refused("too_dense") put(q, pair, count) ceiling = q else: depth = int(mode[-1]) after, filled = 0, False for i in range(n): if s[i] < after: raise Refused("too_dense") codes = [("2", 0x14, 0x23 + depth)] if i == 0 else [] for r in rows[i]: if filled: codes.append(("2", 0x14, 0x2D)) codes += row_codes(*r) filled = True at = s[i] for pair, count in units_of(codes): put(at, pair, count) at += count if at > (e[i] if erase[i] else s[i + 1]): raise Refused("too_dense") if erase[i]: put(e[i], (0x14, 0x2C), 2) filled = False after = e[i] + 2 else: after = at return slots def to_hex(slots): return "".join("%d %02x%02x\n" % (k, slots[k][0], slots[k][1]) for k in sorted(slots)) def from_hex(text): bad = Refused("cc_malformed") if not text.endswith("\n"): raise bad out = [] for line in text[:-1].split("\n"): slot, sp, pair = line.partition(" ") if not sp or not slot.isdigit() or not slot.isascii() or (len(slot) > 1 and slot[0] == "0"): raise bad if len(pair) != 4 or any(ch not in "0123456789abcdef" for ch in pair): raise bad if out and out[-1][0] >= int(slot): raise bad out.append((int(slot), (int(pair[:2], 16), int(pair[2:], 16)))) return out def read_cells(row, cells): real = [k for k in range(32) if cells[k] is not None and not cells[k][2]] if not real: return None i, j = real[0], real[-1] text, flags, space = "", [], [] for k in range(i, j + 1): cell = cells[k] ch = cell[0] if cell is not None and not cell[2] else " " text += ch flags.append(cell is not None and not cell[2] and cell[1] and ch != " ") space.append(ch == " ") ranges = [] for k in range(len(text)): inside = flags[k] or (space[k] and 0 < k < len(text) - 1 and flags[k - 1] and flags[k + 1]) if inside: if ranges and ranges[-1][0] + ranges[-1][1] == k: ranges[-1][1] += 1 else: ranges.append([k, 1]) return {"row": row, "col": i, "text": text, "italic": ranges} def decode608(pairs): """§8.8: the events, as the corpus writes them.""" bad = Refused("cc_malformed") blank = lambda: [[None] * 32 for _ in range(15)] # noqa: E731 shown, hidden = blank(), blank() mode, row, col, it, base = None, 15, 0, False, 15 line_at, acted, prev, events = None, None, None, [] def rows_of(mem): return [r for r in (read_cells(k + 1, mem[k]) for k in range(15)) if r is not None] def commit(): nonlocal line_at r = read_cells(base, shown[base - 1]) if r is not None and line_at is not None: events.append({"line": line_at, "row": r}) line_at = None def put(ch, mid_row=False): nonlocal col if mode is None: raise bad mem, r = (hidden, row) if mode == "pop" else (shown, base) mem[r - 1][col] = (ch, it, mid_row) col = min(col + 1, 31) for slot, (x, y) in pairs: if prev is not None and slot <= prev: raise bad prev = slot if bin(x).count("1") % 2 == 0 or bin(y).count("1") % 2 == 0: raise bad a, b = x & 0x7F, y & 0x7F if 0x10 <= a <= 0x1F: if acted is not None and acted[0] == (a, b) and acted[1] + 1 == slot: acted = None continue acted = ((a, b), slot) if b >= 0x40: r = next((k for k in range(1, 16) if PAC_FIRST[k] == a and row_base(k) == b & 0x60), None) attr = b & 0x1F if r is None: raise bad if attr == 0x00: c0, i0 = 0, False elif attr == 0x0E: c0, i0 = 0, True elif 0x10 <= attr <= 0x1E and attr % 2 == 0: c0, i0 = (attr - 0x10) * 2, False else: raise bad row, col, it = r, c0, i0 if mode not in (None, "pop"): base = r if line_at is None: line_at = slot elif a == 0x14 and b == 0x20: mode = "pop" elif a == 0x14 and b in (0x25, 0x26, 0x27): if mode == "pop": shown, hidden = blank(), blank() mode, base, col = b - 0x23, 15, 0 elif a == 0x14 and b == 0x2C: if mode not in (None, "pop"): commit() line_at = None shown = blank() events.append({"erase": slot}) elif a == 0x14 and b == 0x2D: if mode in (None, "pop"): raise bad commit() for k in range(base - mode, base - 1): shown[k] = shown[k + 1] shown[base - 1] = [None] * 32 col = 0 elif a == 0x14 and b == 0x2E: hidden = blank() elif a == 0x14 and b == 0x2F: shown, hidden = hidden, shown mode = "pop" events.append({"show": slot, "rows": rows_of(shown)}) elif a == 0x17 and b in (0x21, 0x22, 0x23): col = min(col + b - 0x20, 31) elif a == 0x11 and b in (0x20, 0x2E): it = b == 0x2E put(" ", True) elif a == 0x11 and 0x30 <= b <= 0x3F: put(SPECIAL[b - 0x30]) elif a in (0x12, 0x13) and 0x20 <= b <= 0x3F: ch = EXT[a][b - 0x20] if ch == "\x00": raise bad col = max(col - 1, 0) put(ch) else: raise bad else: acted = None for v in (a, b): if v == 0: continue ch = basic(v) if ch is None: raise bad put(ch) if mode not in (None, "pop"): commit() return events def round_trip(mode, rate, cues, events): """§8.8's last paragraph: what a decoder reports is what the cues lay out.""" cues = in_order(cues) n = len(cues) s = [slot_of(c["start"], rate) for c in cues] e = [slot_of(c["end"], rate) for c in cues] erased = [i == n - 1 or s[i + 1] >= e[i] + 2 for i in range(n)] rows = [[{"row": r, "col": c, "text": t, "italic": rg} for r, c, t, rg in layout(mode, cue)] for cue in cues] if mode == "pop-on": want = [] for i in range(n): want.append({"show": s[i], "rows": rows[i]}) if erased[i]: want.append({"erase": e[i]}) return events == want k = 0 for i in range(n): end = e[i] if erased[i] else s[i + 1] for r in rows[i]: if k >= len(events) or events[k].get("row") != r or not (s[i] <= events[k]["line"] < end): return False k += 1 if erased[i]: if k >= len(events) or events[k] != {"erase": e[i]}: return False k += 1 return k == len(events) def paths608(text): seen = set() for _, (x, y) in from_hex(text): a, b = x & 0x7F, y & 0x7F names = {(0x14, 0x20): "RCL", (0x14, 0x25): "RU2", (0x14, 0x26): "RU3", (0x14, 0x27): "RU4", (0x14, 0x2C): "EDM", (0x14, 0x2D): "CR", (0x14, 0x2E): "ENM", (0x14, 0x2F): "EOC", (0x17, 0x21): "TO1", (0x17, 0x22): "TO2", (0x17, 0x23): "TO3", (0x11, 0x20): "MRC white", (0x11, 0x2E): "MRC italics"} if (a, b) in names: seen.add(names[(a, b)]) elif a == 0x11 and 0x30 <= b <= 0x3F: seen.add("special") elif a in (0x12, 0x13) and 0x20 <= b <= 0x3F: seen.add("extended 0x%x" % a) elif 0x10 <= a <= 0x1F and b >= 0x40: seen.add("PAC italics" if b & 0x1F == 0x0E else "PAC indent") else: seen.add("standard") return seen PATHS608 = {"RCL", "RU2", "RU3", "RU4", "EDM", "CR", "ENM", "EOC", "TO1", "TO2", "TO3", "MRC white", "MRC italics", "special", "extended 0x12", "extended 0x13", "PAC italics", "PAC indent", "standard"} # ---------------------------------------------------------------- §4 live captions def read_annotation(b): """§4.2's layout.""" if len(b) < 42 or b[:4] != b"WTX1" or b[4] != 1 or b[37] not in (0, 1) or b[40] > 5 or len(b) != 42 + 4 * b[41]: raise Refused("annotation_malformed") italic = [[int.from_bytes(b[42 + 4 * i:44 + 4 * i], "little"), int.from_bytes(b[44 + 4 * i:46 + 4 * i], "little")] for i in range(b[41])] return {"supersedes": b[5:37], "final": b[37] == 1, "stable": int.from_bytes(b[38:40], "little"), "anchor": ANCHORS[b[40]], "italic": italic} def annotation_of(p): return p["kind"] == META and p["bytes"][:4] == b"WTX1" class Captions: """§4.3 over a live-events verifier.""" def __init__(self, room): self.ev = LEV.Verifier(room, DEFAULT_POLICY, {}) self.revs = [] # dicts, in stream order self.held = {} # principal -> {seq: revision position or None} self.whole = {} # principal -> no gap so far self.waiting = {} # principal -> revision position self.n = 0 def settle(self, k, unknown): r = self.revs[k] r.update(final=True, stable=len(r["text"]), unknown=unknown) def offer(self, b): index = self.n self.n += 1 before = len(self.ev.accepted) self.ev.offer(b) # raises LEV.Refuse if len(self.ev.accepted) == before: return # a duplicate _, ev, p = self.ev.accepted[-1] who, seq = ev["principal"], ev["seq"] held = self.held.setdefault(who, {}) if who not in self.whole: self.whole[who] = True top = max(held) if held else None if (top is None and seq > 0) or (top is not None and seq > top + 1): self.whole[who] = False mine = self.waiting.pop(who, None) ann = annotation_of(p) target = None if mine is not None: cseq = self.revs[mine]["event"]["seq"] if seq == cseq + 1 and ann: target = mine else: self.settle(mine, seq != cseq + 1) if p["kind"] == CAPTION: if not text_ok(p["text"]): raise Refused("text_not_in_subset") self.revs.append({"index": index, "event": ev, "text": p["text"], "lang": p["lang"], "duration": p["duration_ms"], "origin": ORIGINS[p["origin"]], "model": None if p["model"] is None else p["model"].hex(), "annotation": None, "unknown": False, "final": False, "stable": 0, "anchor": "bottom-center", "italic": [], "sup": None, "sup_digest": None, "by": None}) held[seq] = len(self.revs) - 1 self.waiting[who] = len(self.revs) - 1 elif ann: held[seq] = None if target is not None: self.attach(index, target, ev, p["bytes"]) elif seq > 0 and (seq - 1) in held: raise Refused("annotation_misplaced") else: held[seq] = None def attach(self, index, k, ev, b): a = read_annotation(b) r = self.revs[k] if ev["t"] != r["event"]["t"]: raise Refused("annotation_misplaced") if a["stable"] > len(r["text"]) or (a["final"] and a["stable"] != len(r["text"])) or not ranges_ok(r["text"], a["italic"]): raise Refused("annotation_malformed") sup = None if a["supersedes"] != bytes(32): who = ev["principal"] sup = next((j for j, x in enumerate(self.revs) if x["event"]["principal"] == who and x["event"]["digest"] == a["supersedes"]), None) if sup is None: if self.whole[who]: raise Refused("supersede_unknown") else: old = self.revs[sup] if old["final"]: raise Refused("supersede_final") if old["by"] is not None: raise Refused("supersede_forked") if old["lang"] != r["lang"] or old["anchor"] != a["anchor"]: raise Refused("supersede_mismatch") if not r["text"].startswith(old["text"][:old["stable"]]): raise Refused("stability_broken") old["by"] = k r.update(annotation=index, final=a["final"], stable=a["stable"], anchor=a["anchor"], italic=a["italic"], sup=sup, sup_digest=None if a["supersedes"] == bytes(32) else a["supersedes"]) def finish(self): for k in list(self.waiting.values()): self.settle(k, False) self.waiting = {} def shown(self, x, names): out = [] for k, r in enumerate(self.revs): t = r["event"]["t"] if t > x or (r["by"] is not None and self.revs[r["by"]]["event"]["t"] <= x): continue if r["duration"]: live = x < t + r["duration"] * 1000 else: live = not any(n["event"]["principal"] == r["event"]["principal"] and n["event"]["seq"] > r["event"]["seq"] and n["event"]["t"] <= x for n in self.revs[k + 1:]) if live: out.append({"principal": names[r["event"]["principal"].hex()], "text": r["text"], "anchor": r["anchor"], "origin": r["origin"], "italic": r["italic"], "final": r["final"]}) return {"at": x, "cues": out} def revisions(self): return [{"index": r["index"], "annotation": r["annotation"], "final": r["final"], "unknown": r["unknown"], "stable_cp": r["stable"], "anchor": r["anchor"], "italic": r["italic"], "supersedes": None if r["sup"] is None else self.revs[r["sup"]]["index"]} for r in self.revs] def resolve(self, pt0): """§4.5: (cues with their publication times) or Refused with an index.""" out = [] for head in self.revs: if head["sup"] is not None or head["sup_digest"] is not None: continue last = head while last["by"] is not None: last = self.revs[last["by"]] if not last["final"] or last["unknown"]: raise Refused(("not_final", last["index"])) if last["duration"] == 0: raise Refused(("duration_unknown", last["index"])) pub = pt0 + last["event"]["t"] // 1000 cue = {"start": pt0 + head["event"]["t"] // 1000, "end": pub + last["duration"], "lang": last["lang"], "origin": last["origin"], "model": last["model"], "anchor": last["anchor"], "text": last["text"], "italic": last["italic"]} if cue["end"] > LAST_MS: raise Refused(("time_out_of_range", last["index"])) out.append((cue["start"], head["index"], cue, pub)) out.sort(key=lambda x: (x[0], x[1])) return [(c, p) for _, _, c, p in out] def verify_events(room, records): """§4.3's outcome: (outcome dict, Captions).""" cap = Captions(room) for i, b in enumerate(records): try: cap.offer(b) except (LEV.Refuse, Refused) as e: return {"outcome": "refuse", "reason": e.reason, "index": i}, cap cap.finish() if cap.ev.gaps: return {"outcome": "gap", "gaps": [[g[0], g[1], g[2]] for g in cap.ev.gaps]}, cap bad = LEV.stated_tally_mismatch(cap.ev.accepted) if bad is not None: return {"outcome": "refuse", "reason": "tally_mismatch", "index": bad}, cap return {"outcome": "accept"}, cap def join_set(mine, group): """§4.6 on one principal's (event, payload) list: the seqs of its join set.""" start = group * GROUP_US before = [(e, p) for e, p in mine if e["t"] < start] by_seq = {e["seq"]: (e, p) for e, p in before} keep = set() # live-events §8.2. closed = {p["poll"] for _, p in before if p["kind"] == 0x07} open_polls = {e["digest"] for e, p in before if p["kind"] == 0x05 and e["digest"] not in closed} last_vote, ins = {}, {} for e, p in before: if p["kind"] == 0x06 and p["poll"] in open_polls: last_vote[p["poll"]] = e["seq"] if p["kind"] == 0x02 and p["type"] == 2: ins[p["cue_id"]] = e["seq"] for e, p in before: k = p["kind"] if (k == 0x05 and e["digest"] in open_polls) or (k == 0x06 and last_vote.get(p["poll"]) == e["seq"]): keep.add(e["seq"]) elif k == CAPTION and p["duration_ms"] > 0 and e["t"] + p["duration_ms"] * 1000 > start: keep.add(e["seq"]) elif k == 0x02 and p["type"] in (1, 4) and not (p["cue_id"] in ins and ins[p["cue_id"]] > e["seq"]): keep.add(e["seq"]) # §4.6. superseded = set() for e, p in before: if annotation_of(p) and (e["seq"] - 1) in by_seq and by_seq[e["seq"] - 1][1]["kind"] == CAPTION: try: superseded.add(read_annotation(p["bytes"])["supersedes"]) except Refused: pass captions = [e["seq"] for e, p in before if p["kind"] == CAPTION] for e, p in before: if p["kind"] != CAPTION or p["duration_ms"] != 0 or e["seq"] != max(captions) or e["digest"] in superseded: continue nxt = by_seq.get(e["seq"] + 1) if nxt and annotation_of(nxt[1]): try: if not read_annotation(nxt[1]["bytes"])["final"]: keep.add(e["seq"]) except Refused: pass for s in sorted(keep): if s in by_seq and by_seq[s][1]["kind"] == CAPTION and (s + 1) in by_seq and annotation_of(by_seq[s + 1][1]): keep.add(s + 1) return sorted(keep) def join_stream(records, group): parsed = [] for b in records: e = LEV.read_record(b) parsed.append((e, LEV.read_payload(e["kind"], e["payload"], 500))) out, idx = [], [] for who in sorted(set(e["principal"] for e, _ in parsed)): mine = [(e, p) for e, p in parsed if e["principal"] == who] for s in join_set(mine, group): i = next(k for k, (e, _) in enumerate(parsed) if e["principal"] == who and e["seq"] == s) idx.append(i) out.append(records[i]) out += [records[k] for k, (e, _) in enumerate(parsed) if e["t"] >= group * GROUP_US] return out, idx # ---------------------------------------------------------------- the corpus def attempt(f, *a): try: return f(*a), None except Refused as e: return None, e.reason def targets_of(name, lang, published, stated, written): """The targets object, from this text; `written` collects (file, contents).""" cues = [c for c, _ in published] out = {} def file_or_reason(path, f, *a): value, why = attempt(f, *a) if why: return {"reason": why} written[path] = value return {"file": path} if "webvtt" in stated: out["webvtt"] = file_or_reason(name + ".vtt", webvtt, lang, cues) if "imsc" in stated: out["imsc"] = file_or_reason(name + ".ttml", imsc, lang, cues) if "segments" in stated: s = stated["segments"] o = {"bounds": s["bounds"], "mpegts0": s["mpegts0"]} segs, why = attempt(webvtt_segments, lang, cues, s["bounds"], s["mpegts0"]) if why: o["reason"] = why else: o["files"] = [] for k, seg in enumerate(segs): p = "%s.seg%d.vtt" % (name, k) written[p] = seg o["files"].append(p) out["segments"] = o if "timelines" in stated: s = stated["timelines"] o = {"group_ms": s["group_ms"], "first": s["first"], "last": s["last"]} for kind in ("webvtt", "imsc"): groups, why = attempt(timeline, kind, lang, published, s["group_ms"], s["first"], s["last"]) if why: o["reason"] = why else: o[kind] = groups out["timelines"] = o if "cc608" in stated: lst = [] for e in stated["cc608"]: mode, rate = e["mode"], tuple(e["rate"]) o = {"mode": mode, "rate": list(rate)} slots, why = attempt(encode608, mode, rate, lang, cues) if why: o["reason"] = why else: suffix = "" if rate == (30000, 1001) else ".r%d-%d" % rate p = "%s.%s%s.608.hex" % (name, mode, suffix) written[p] = to_hex(slots) o["file"] = p o["decoded"] = decode608(sorted(slots.items())) if not round_trip(mode, rate, cues, o["decoded"]): o["decoded"] = "does not round-trip" lst.append(o) out["cc608"] = lst if "disclosure" in stated: chars = ["public.accessibility.transcribes-spoken-dialog", "public.accessibility.describes-music-and-sound"] out["disclosure"] = { "hls_subtitles": media_tag("SUBTITLES", "subs", "Captions", lang, chars, cues, name + "/captions.m3u8"), "hls_closed_captions": media_tag("CLOSED-CAPTIONS", "cc", "Captions", lang, chars, cues), "session_data": '#EXT-X-SESSION-DATA:DATA-ID="science.transaction.wai.timed-text",URI="%s.disclosure.json",LANGUAGE="%s"' % (name, lang), "sidecar": sidecar(lang, cues), "c2pa": c2pa(cues), "catalog_webvtt": catalog("webvtt", "captions-" + lang, lang, ["video"], cues), "catalog_imsc": catalog("imsc", "captions-%s-imsc" % lang, lang, ["video"], cues), } return out def check_targets(name, lang, published, stated, files, fails, seen): written = {} try: got = targets_of(name, lang, published, stated, written) except Exception as e: # a mutated copy that this text cannot map is a failure, not a crash fails.append("%s: the targets do not map: %s: %s" % (name, type(e).__name__, e)) return if got != stated: keys = sorted(k for k in set(got) | set(stated) if got.get(k) != stated.get(k)) fails.append("%s: targets %s differ from this text's" % (name, keys)) for p, body in written.items(): if files.get(p) != body: fails.append("%s: %s differs from this text's rendering" % (name, p)) cues = in_order([c for c, _ in published]) w = stated.get("webvtt", {}) if "file" in w: back, why = attempt(read_webvtt, files.get(w["file"], ""), lang) if why or back != (cues, None): fails.append("%s: its WebVTT does not read back (%s)" % (name, why)) s = stated.get("segments", {}) for k, f in enumerate(s.get("files", [])): lo, hi = s["bounds"][k], s["bounds"][k + 1] back, why = attempt(read_webvtt, files.get(f, ""), lang) want = [c for c in cues if c["start"] < hi and c["end"] > lo] if why or back[0] != want or back[1][0] != lo: fails.append("%s: segment %d does not read back (%s)" % (name, k, why)) m = stated.get("imsc", {}) if "file" in m: for f in imsc_structure(files.get(m["file"], ""), cues): fails.append("%s: IMSC1 structure: %s" % (name, f)) for e in stated.get("cc608", []): if "reason" in e: seen.add(e["reason"]) if "file" in e: try: dec = decode608(from_hex(files.get(e["file"], ""))) except Refused as r: dec = r.reason if dec != e.get("decoded"): fails.append("%s: %s decodes otherwise than stated" % (name, e["file"])) for k in ("webvtt", "imsc", "segments", "timelines"): if "reason" in stated.get(k, {}): seen.add(stated[k]["reason"]) def check(doc, files, quiet=False): fails, seen = [], set() pt0 = doc["pt0_ms"] room = bytes.fromhex(doc["room"]) names = {v: k for k, v in doc["principals"].items()} for v in doc["event_vectors"]: name = v["name"] try: records = [bytes.fromhex(h) for h in v["events"]] out, cap = verify_events(room, records) except Exception as e: fails.append("%s: does not verify: %s: %s" % (name, type(e).__name__, e)) continue if "gaps" in out: out["gaps"] = [[names[g[0].hex()], g[1], g[2]] for g in out["gaps"]] for k in ("outcome", "reason", "index", "gaps"): if out.get(k) != v.get(k): fails.append("%s: %s is %s, the corpus states %s" % (name, k, out.get(k), v.get(k))) if v.get("reason"): seen.add(v["reason"]) if not quiet: print("%s event vector %s: %s" % ("PASS" if not fails or not fails[-1].startswith(name + ":") else "FAIL", name, out["outcome"] + (" " + out.get("reason", "") if out.get("reason") else ""))) if out["outcome"] == "refuse": continue if cap.revisions() != v.get("revisions"): fails.append("%s: the revisions differ" % name) for s in v.get("shown", []): if cap.shown(s["at"], names) != s: fails.append("%s: what is shown at %d differs" % (name, s["at"])) try: published = cap.resolve(pt0) except Refused as r: why, at = r.reason seen.add(why) if v.get("resolve") != {"reason": why, "index": at}: fails.append("%s: resolves to %s at %d, the corpus states %s" % (name, why, at, v.get("resolve"))) published = None if published is not None: want = {"cues": [c for c, _ in published], "published": [p for _, p in published]} if v.get("resolve") != want: fails.append("%s: the resolved cues differ" % name) check_targets(name, "en", published, v.get("targets", {}), files, fails, seen) j = v.get("join") if j is not None: stream, idx = join_stream(records, j["group"]) if [b.hex() for b in stream] != j["stream"] or idx != j["join_set"]: fails.append("%s: the joiner's records differ" % name) jo, jcap = verify_events(room, stream) if jo.get("outcome") != j.get("outcome") or [[names[g[0].hex()], g[1], g[2]] for g in jo.get("gaps", [])] != j.get("gaps"): fails.append("%s: the joiner's outcome differs" % name) for s in j.get("shown", []): if jcap.shown(s["at"], names) != s or cap.shown(s["at"], names) != s: fails.append("%s: the joiner does not show what the whole stream shows at %d" % (name, s["at"])) # On a timeline, a joiner at any group holds every cue not yet ended (§7.3). tl = v.get("targets", {}).get("timelines", {}) for g in tl.get("webvtt", []): lo = g["group"] * tl["group_ms"] held = set() for later in tl["webvtt"]: if later["group"] >= g["group"]: for obj in later["objects"]: held |= {r["data"]["start"] for r in json.loads(obj)} need = {c["start"] for c, _ in (published or []) if c["end"] > lo} if not need <= held: fails.append("%s: a joiner at group %d lacks a cue" % (name, g["group"])) for v in doc["cue_vectors"]: published = [(c, c["start"]) for c in v["cues"]] before = len(fails) check_targets(v["name"], v["lang"], published, v["targets"], files, fails, seen) if not quiet: print("%s cue vector %s" % ("PASS" if len(fails) == before else "FAIL", v["name"])) for c in doc["read_cases"]: _, why = attempt(read_webvtt, c["text"], c["lang"]) seen.add(why) if why != c["reason"]: fails.append("read case %s: %s, the corpus states %s" % (c["name"], why, c["reason"])) for c in doc["decode_cases"]: try: decode608(from_hex(c["hex"])) why = None except Refused as r: why = r.reason seen.add(why) if why != c["reason"]: fails.append("decode case %s: %s, the corpus states %s" % (c["name"], why, c["reason"])) missing = [r for r in REASONS if r not in seen] if missing: fails.append("nothing is refused %s" % ", ".join(missing)) paths = set() for p, body in files.items(): if p.endswith(".608.hex"): try: paths |= paths608(body) except Refused: fails.append("%s does not read" % p) if PATHS608 - paths: fails.append("no 608 file sends %s" % ", ".join(sorted(PATHS608 - paths))) return fails def mutations(doc, files): """Copies of the corpus, each with one defect a conforming verifier must catch.""" out = [] def mutate(label, f): d, fs = copy.deepcopy(doc), dict(files) f(d, fs) out.append((label, d, fs)) def vec(d, kind, name): return next(v for v in d[kind] if v["name"] == name) def flip_hex(h, at): b = bytearray(bytes.fromhex(h)) b[at] ^= 0x01 return b.hex() def swap_lines(text, i): lines = text.split("\n") lines[i], lines[i + 1] = lines[i + 1], lines[i] return "\n".join(lines) mutate("a letter of the machine lesson's WebVTT", lambda d, fs: fs.update({"lesson_machine.vtt": fs["lesson_machine.vtt"].replace("twice", "twise")})) mutate("the machine label taken out of disclosure_mix.ttml", lambda d, fs: fs.update({"disclosure_mix.ttml": fs["disclosure_mix.ttml"].replace(' wai:origin="machine-reviewed"', "", 1)})) mutate("the machine label taken out of disclosure_mix.vtt", lambda d, fs: fs.update({"disclosure_mix.vtt": fs["disclosure_mix.vtt"].replace("wai-machine-reviewed", "wai-machine", 1)})) mutate("a byte pair of the human lesson's 608", lambda d, fs: fs.update( {"lesson_human.pop-on.608.hex": fs["lesson_human.pop-on.608.hex"].replace(" 9420\n", " 9429\n", 1)})) mutate("two lines of the roll-up 608 swapped", lambda d, fs: fs.update({"cc_rollup.roll-up-3.608.hex": swap_lines(fs["cc_rollup.roll-up-3.608.hex"], 3)})) mutate("a byte of the machine lesson's third record", lambda d, fs: vec(d, "event_vectors", "lesson_machine")["events"].__setitem__( 2, flip_hex(vec(d, "event_vectors", "lesson_machine")["events"][2], 150))) mutate("the human lesson's stated end of its first cue", lambda d, fs: vec(d, "event_vectors", "lesson_human")["resolve"]["cues"][0].update(end=13600)) mutate("an anchor of styles_and_anchors", lambda d, fs: vec(d, "cue_vectors", "styles_and_anchors")["cues"][1].update(anchor="bottom-center")) mutate("an italic range of styles_and_anchors", lambda d, fs: vec(d, "cue_vectors", "styles_and_anchors")["cues"][7].update(italic=[[5, 7]])) mutate("the stated reason of supersede_forked", lambda d, fs: vec(d, "event_vectors", "supersede_forked").update(reason="supersede_final")) mutate("object 0 of the join vector's group 35", lambda d, fs: vec(d, "event_vectors", "mid_stream_join")["targets"]["timelines"]["webvtt"][2]["objects"].__setitem__(0, "[]")) mutate("what the joiner shows at 60 s", lambda d, fs: vec(d, "event_vectors", "mid_stream_join")["join"]["shown"][0]["cues"].pop()) mutate("the C2PA statement of disclosure_mix", lambda d, fs: vec(d, "cue_vectors", "disclosure_mix")["targets"]["disclosure"].update( c2pa=vec(d, "cue_vectors", "disclosure_mix")["targets"]["disclosure"]["c2pa"].replace("human_validated", "fully_autonomous", 1))) mutate("a sidecar origin of disclosure_mix", lambda d, fs: vec(d, "cue_vectors", "disclosure_mix")["targets"]["disclosure"].update( sidecar=vec(d, "cue_vectors", "disclosure_mix")["targets"]["disclosure"]["sidecar"].replace('"origin":"machine"', '"origin":"human"', 1))) mutate("the machine characteristic of the machine lesson's HLS tag", lambda d, fs: vec(d, "event_vectors", "lesson_machine")["targets"]["disclosure"].update( hls_closed_captions=vec(d, "event_vectors", "lesson_machine")["targets"]["disclosure"]["hls_closed_captions"].replace(",public.machine-generated", ""))) mutate("a stated show slot of the human lesson's 608", lambda d, fs: vec(d, "event_vectors", "lesson_human")["targets"]["cc608"][0]["decoded"][0].update( show=vec(d, "event_vectors", "lesson_human")["targets"]["cc608"][0]["decoded"][0]["show"] + 1)) mutate("the stated reason of a read case", lambda d, fs: d["read_cases"][0].update(reason="cue_malformed")) mutate("a segment's X-TIMESTAMP-MAP", lambda d, fs: fs.update({"lesson_human.seg1.vtt": fs["lesson_human.seg1.vtt"].replace("MPEGTS:2160000", "MPEGTS:2160090")})) mutate("ttp:profile taken off lesson_human.ttml's tt", lambda d, fs: fs.update( {"lesson_human.ttml": fs["lesson_human.ttml"].replace(' ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text"', "")})) mutate("a span of styles_and_anchors.ttml no longer italic", lambda d, fs: fs.update( {"styles_and_anchors.ttml": fs["styles_and_anchors.ttml"].replace('tts:fontStyle="italic"', 'tts:fontStyle="normal"', 1)})) return out def main(corpus_dir): with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f: doc = json.load(f) files = {} for n in sorted(os.listdir(corpus_dir)): if n not in ("vectors.json", "README.md"): with open(os.path.join(corpus_dir, n), encoding="utf-8", newline="") as f: files[n] = f.read() fails = check(doc, files) # Every file is one this text writes: none left over. written = set() for v in doc["event_vectors"] + doc["cue_vectors"]: t = v.get("targets", {}) for k in ("webvtt", "imsc"): if "file" in t.get(k, {}): written.add(t[k]["file"]) written |= set(t.get("segments", {}).get("files", [])) written |= {e["file"] for e in t.get("cc608", []) if "file" in e} if set(files) != written: fails.append("files not stated by a vector: %s; stated and absent: %s" % (sorted(set(files) - written), sorted(written - set(files)))) print("%s every vector, case and file agrees with this text (%d event vectors, %d cue vectors, %d files)" % ("PASS" if not fails else "FAIL", len(doc["event_vectors"]), len(doc["cue_vectors"]), len(files))) for label, d, fs in mutations(doc, files): try: caught = check(d, fs, quiet=True) except Exception as e: # a crash on a mutated copy is a failure to read it caught = ["%s: %s" % (type(e).__name__, e)] print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:110] if caught else "")) if not caught: fails.append("a mutated copy verifies: %s" % label) for f in fails: print("FAIL " + f) print(("PASS" if not fails else "FAIL") + " timed-text corpus") return 1 if fails else 0 if __name__ == "__main__": if len(sys.argv) != 2: print("usage: timed_text_verify.py ", file=sys.stderr) sys.exit(2) sys.exit(main(sys.argv[1]))