Phase 12: frame-accurate timecode via an overscan band — vgstrip encoder/decoder, sweep writes tmcd + creation_time + sidecar, dumpd /time, Caddy /time routes, MediaMTX microsecond names, /dock/clock/ page, dock band setup via obs-websocket, Go Live band composite + toggle, portal crops the band
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,9 @@ class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.server.server_port == AUTH_LISTEN[1]:
|
||||
return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only
|
||||
if self.path.startswith("/time"):
|
||||
# server clock for the timecode strip (the /clock/ page and Go Live measure their offset against this)
|
||||
return self._json(200, {"now_ms": int(_time.time() * 1000)})
|
||||
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
|
||||
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
|
||||
who, _ = identity(self.headers)
|
||||
|
||||
@@ -104,9 +104,32 @@ while IFS= read -r -d '' src; do
|
||||
fi
|
||||
tmp="$out.part"
|
||||
log "$verdict $src ($reason)"
|
||||
tcargs=(); tcjson=""
|
||||
if (( live )); then
|
||||
# timecode: decode the overscan strip (server clock) or fall back to the server arrival time in the filename
|
||||
stem="$(basename "${src%.*}")"
|
||||
arrival_ms=$(python3 - "$stem" <<'PYEOF'
|
||||
import re, sys
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
m = re.search(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})(?:-(\d{1,6}))?", sys.argv[1])
|
||||
if not m: sys.exit(0)
|
||||
y, mo, d, h, mi, s, us = m.groups()
|
||||
us = int((us or "0").ljust(6, "0"))
|
||||
t = datetime(int(y), int(mo), int(d), int(h), int(mi), int(s), us, tzinfo=ZoneInfo("America/New_York"))
|
||||
print(int(t.timestamp() * 1000))
|
||||
PYEOF
|
||||
)
|
||||
eval "$(python3 /usr/local/bin/vgstrip.py plan "$src" "${arrival_ms:-}" 2>/dev/null || true)"
|
||||
if [[ -n ${TC_TIMECODE:-} ]]; then
|
||||
tcargs=(-timecode "$TC_TIMECODE" -metadata "creation_time=$TC_CREATION" -write_tmcd 1)
|
||||
tcjson="${TC_JSON:-}"
|
||||
log "tc $src source=$TC_SOURCE timecode=$TC_TIMECODE"
|
||||
fi
|
||||
fi
|
||||
if [[ $verdict == remux ]]; then
|
||||
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
|
||||
-map 0 -c copy -movflags +faststart -f mp4 "$tmp"
|
||||
-map 0 -c copy "${tcargs[@]}" -movflags +faststart -f mp4 "$tmp"
|
||||
else
|
||||
# scale down into the cap (never up), keep aspect, even dimensions; cap fps; CFR; all audio -> AAC
|
||||
vf="scale='min(iw,$MAX_W)':'min(ih,$MAX_H)':force_original_aspect_ratio=decrease:force_divisible_by=2"
|
||||
@@ -117,13 +140,16 @@ while IFS= read -r -d '' src; do
|
||||
-c:v libx264 -preset veryfast -crf 18 -maxrate 30M -bufsize 60M -pix_fmt yuv420p -profile:v high \
|
||||
-vf "$vf" -fps_mode cfr "${fpsargs[@]}" \
|
||||
-c:a aac -b:a 160k \
|
||||
-movflags +faststart -f mp4 "$tmp"
|
||||
"${tcargs[@]}" -movflags +faststart -f mp4 "$tmp"
|
||||
fi
|
||||
rc=$?
|
||||
d1=$(dur "$src"); d2=$(dur "$tmp")
|
||||
if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then
|
||||
if (( live )); then
|
||||
mv -f "$tmp" "$out"; chown 1000:1000 "$out" 2>/dev/null
|
||||
if [[ -n $tcjson ]]; then # sidecar: exact capture time (read by the Jellyfin plugin) + drift samples
|
||||
printf '%s\n' "$tcjson" > "${out%.*}.json"; chown 1000:1000 "${out%.*}.json" 2>/dev/null
|
||||
fi
|
||||
rm -f "$src"; rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder
|
||||
log "done $out"; continue
|
||||
elif (( synced )); then
|
||||
|
||||
160
bin/vgstrip.py
Executable file
160
bin/vgstrip.py
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vanguard timecode strip — shared format (encoder for tests, decoder for the sweep).
|
||||
|
||||
The published canvas is the game image plus an "overscan band" of extra rows at the bottom. Everything is
|
||||
expressed relative to the canvas WIDTH so any uniform rescale keeps the geometry:
|
||||
|
||||
cell = W / 160 (16 px at 2560, 12 px at 1920)
|
||||
band = round_even(cell) rows at the bottom of the frame
|
||||
strip = 64 cells starting at x = cell, vertically centred in the band:
|
||||
[1,0,1,0] framing · 48-bit server time in ms (MSB first) · 8-bit CRC-8 (poly 0x07) · [0,1,0,1] framing
|
||||
1 = white, 0 = black
|
||||
text = human-readable server clock to the right of the strip (not decoded)
|
||||
|
||||
Decode: sample the centre of each cell on the row y = H - band/2; threshold at the midpoint of the framing
|
||||
cells' brightness; verify framing + CRC. The decoded value is SERVER time at render; frame-0 capture time =
|
||||
median(decoded_k - pts_k) over several frames.
|
||||
"""
|
||||
import sys, json, struct, subprocess, statistics
|
||||
|
||||
CELLS = 64
|
||||
FRAME_A = [1, 0, 1, 0]
|
||||
FRAME_B = [0, 1, 0, 1]
|
||||
|
||||
def crc8(data: bytes, poly=0x07) -> int:
|
||||
c = 0
|
||||
for b in data:
|
||||
c ^= b
|
||||
for _ in range(8):
|
||||
c = ((c << 1) ^ poly) & 0xFF if c & 0x80 else (c << 1) & 0xFF
|
||||
return c
|
||||
|
||||
def encode_bits(ms: int):
|
||||
payload = ms.to_bytes(6, "big")
|
||||
bits = [(payload[i // 8] >> (7 - i % 8)) & 1 for i in range(48)]
|
||||
c = crc8(payload)
|
||||
bits += [(c >> (7 - i)) & 1 for i in range(8)]
|
||||
return FRAME_A + bits + FRAME_B
|
||||
|
||||
def decode_bits(bits):
|
||||
if bits[:4] != FRAME_A or bits[-4:] != FRAME_B: return None
|
||||
body = bits[4:-4]
|
||||
payload = bytes(int("".join(map(str, body[i:i + 8])), 2) for i in range(0, 48, 8))
|
||||
c = int("".join(map(str, body[48:56])), 2)
|
||||
if crc8(payload) != c: return None
|
||||
return int.from_bytes(payload, "big")
|
||||
|
||||
def geometry(w):
|
||||
cell = w / 160.0
|
||||
band = int(round(cell)); band += band % 2
|
||||
return cell, band
|
||||
|
||||
def cell_centres(w):
|
||||
cell, band = geometry(w)
|
||||
return [int(round(cell * (1 + i + 0.5))) for i in range(CELLS)], band
|
||||
|
||||
# ------------------------------------------------------------------ decoder over a raw gray band
|
||||
def decode_band_row(row: bytes, w: int):
|
||||
"""row = one grayscale row (w bytes) taken from the middle of the band."""
|
||||
xs, _ = cell_centres(w)
|
||||
vals = []
|
||||
for x in xs:
|
||||
lo, hi = max(0, x - 1), min(w, x + 2)
|
||||
vals.append(sum(row[lo:hi]) / max(1, hi - lo))
|
||||
fr = vals[:4] + vals[-4:]
|
||||
thr = (min(fr) + max(fr)) / 2.0
|
||||
if max(fr) - min(fr) < 40: return None # no contrast: strip absent
|
||||
return decode_bits([1 if v > thr else 0 for v in vals])
|
||||
|
||||
def probe(path):
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=width,height,avg_frame_rate:format=duration", "-of", "json", path],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
j = json.loads(out); st = j["streams"][0]
|
||||
num, den = st["avg_frame_rate"].split("/"); fps = float(num) / float(den or 1)
|
||||
return int(st["width"]), int(st["height"]), fps, float(j["format"]["duration"])
|
||||
|
||||
def sample_window(path, w, h, fps, start_s, dur_s):
|
||||
"""Decode every frame in [start, start+dur). Returns [(pts_s, server_ms)]."""
|
||||
import re
|
||||
_, band = geometry(w)
|
||||
y0 = h - band
|
||||
# -copyts keeps the file's own timestamps; showinfo prints the exact pts of every emitted frame
|
||||
cmd = ["ffmpeg", "-nostdin", "-v", "info", "-copyts", "-ss", f"{start_s:.3f}", "-t", f"{dur_s:.3f}", "-i", path,
|
||||
"-an", "-vf", f"crop={w}:{band}:0:{y0},format=gray,showinfo", "-f", "rawvideo", "-"]
|
||||
r = subprocess.run(cmd, capture_output=True, check=True)
|
||||
raw = r.stdout
|
||||
pts = [float(m) for m in re.findall(r"\bpts_time:\s*([0-9.]+)", r.stderr.decode(errors="replace"))]
|
||||
frame_len = w * band
|
||||
res = []
|
||||
for k in range(min(len(raw) // frame_len, len(pts))):
|
||||
row = raw[k * frame_len + (band // 2) * w: k * frame_len + (band // 2) * w + w]
|
||||
ms = decode_band_row(row, w)
|
||||
if ms is not None:
|
||||
res.append((pts[k], ms))
|
||||
return res
|
||||
|
||||
def first_pts(path):
|
||||
"""pts of the first video frame (fMP4 from x264 with B-frames starts ~2 frames after 0)."""
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts_time",
|
||||
"-read_intervals", "%+#1", "-of", "csv=p=0", path], capture_output=True, text=True).stdout.strip()
|
||||
try: return float(out.splitlines()[0].strip(",").split(",")[0])
|
||||
except Exception: return 0.0
|
||||
|
||||
def analyse(path, win_s=4.0):
|
||||
w, h, fps, dur = probe(path)
|
||||
p0 = first_pts(path) # timecode start is the FIRST FRAME, not pts 0
|
||||
windows = [0.0]
|
||||
if dur > 60: windows.append(max(0.0, dur / 2 - win_s / 2))
|
||||
if dur > 20: windows.append(max(0.0, dur - win_s - 0.5))
|
||||
samples = []
|
||||
for ws in windows:
|
||||
pts = sample_window(path, w, h, fps, ws, win_s)
|
||||
if pts:
|
||||
offs = [ms - (pts_s - p0) * 1000.0 for pts_s, ms in pts]
|
||||
samples.append({"at_s": round(ws, 3), "frames": len(pts), "t0_ms": int(statistics.median(offs))})
|
||||
if not samples:
|
||||
return {"source": "none", "fps": fps, "width": w, "height": h, "duration_s": dur}
|
||||
t0 = samples[0]["t0_ms"]
|
||||
drift = samples[-1]["t0_ms"] - t0 if len(samples) > 1 else 0
|
||||
return {"source": "strip", "t0_ms": t0, "drift_ms": drift, "fps": fps, "width": w, "height": h,
|
||||
"duration_s": dur, "samples": samples}
|
||||
|
||||
def timecode_string(t0_ms, fps, tz="America/New_York"):
|
||||
"""Time-of-day timecode for the tmcd track: HH:MM:SS:FF (drop-frame ';' for 29.97/59.94)."""
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
local = datetime.fromtimestamp(t0_ms / 1000.0, timezone.utc).astimezone(ZoneInfo(tz))
|
||||
frac = (t0_ms % 1000) / 1000.0
|
||||
nominal = round(fps)
|
||||
ff = min(nominal - 1, int(frac * fps))
|
||||
sep = ";" if abs(fps - nominal) > 0.01 else ":"
|
||||
return f"{local:%H:%M:%S}{sep}{ff:02d}", local
|
||||
|
||||
def plan(path, arrival_ms=None, tz="America/New_York"):
|
||||
"""Decide the timecode for a recording: strip if present, else server arrival time. Prints shell vars."""
|
||||
from datetime import datetime, timezone
|
||||
r = analyse(path)
|
||||
if r.get("source") == "strip":
|
||||
t0 = r["t0_ms"]; src = "strip"
|
||||
elif arrival_ms:
|
||||
t0 = int(arrival_ms); src = "arrival"
|
||||
else:
|
||||
t0 = None; src = "none"
|
||||
out = {"source": src, "fps": r.get("fps"), "drift_ms": r.get("drift_ms", 0), "samples": r.get("samples", [])}
|
||||
if t0 is not None:
|
||||
tc, local = timecode_string(t0, r.get("fps") or 60.0, tz)
|
||||
utc = datetime.fromtimestamp(t0 / 1000.0, timezone.utc)
|
||||
out.update({"t0_ms": t0, "timestamp": utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t0 % 1000:03d}Z",
|
||||
"timecode": tc, "timecode_tz": tz, "local_start": local.isoformat()})
|
||||
print(f"TC_TIMECODE={tc}")
|
||||
print(f"TC_CREATION={utc.strftime('%Y-%m-%dT%H:%M:%S')}.{t0 % 1000:03d}Z")
|
||||
print(f"TC_SOURCE={src}")
|
||||
print("TC_JSON=" + json.dumps(json.dumps(out))) # double-encoded: shell-safe single token
|
||||
return out
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2: print(__doc__); sys.exit(2)
|
||||
if sys.argv[1] == "plan":
|
||||
plan(sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else None); sys.exit(0)
|
||||
print(json.dumps(analyse(sys.argv[1]), indent=2))
|
||||
Reference in New Issue
Block a user