open-standards/wai/live-conformance/timed_text_verify.py
2026-10-17 11:32:11 -04:00

1614 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <live-conformance/captions>
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def label_class(c):
return {"machine": "wai-machine", "machine-reviewed": "wai-machine-reviewed"}.get(c["origin"])
def vtt_payload(c):
lines = ["".join("<i>%s</i>" % 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 = "<c.%s.wai-model-%s>%s</c>" % (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 (("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">")):
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("<c."):
tag, gt, inner = body[3:].partition(">")
if not gt or not inner.endswith("</c>"):
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("<i>"):
inner, sep, after = rest_line[3:].partition("</i>")
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 = [
'<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>',
'<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>',
]
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('<span tts:fontStyle="italic">%s</span>' % esc(t) if it else esc(t) for it, t in line) for line in runs_of(c)]
return "<p %s>%s</p>" % (attrs, "<br/>".join(lines))
def imsc(lang, cues):
check_doc(lang, cues)
cues = in_order(cues)
hrm(cues)
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<tt xmlns="%s" xmlns:ttp="%s" xmlns:tts="%s" xmlns:ebuttm="%s" xmlns:wai="%s" xml:lang="%s" ttp:profile="%s">'
% (NS_TT, NS_TTP, NS_TTS, NS_EBUTTM, NS_WAI, lang, PROFILES[0]),
"<head>", "<metadata>", "<ebuttm:documentMetadata>"]
out += ["<ebuttm:conformsToStandard>%s</ebuttm:conformsToStandard>" % p for p in PROFILES]
out += ["</ebuttm:documentMetadata>", "</metadata>", "<layout>"] + REGION_LINES + ["</layout>", "</head>", "<body>", "<div>"]
out += [imsc_p(c) for c in cues]
out += ["</div>", "</body>", "</tt>"]
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('<?xml version="1.0" encoding="UTF-8"?>\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 <live-conformance/captions>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))