Timecode: nominal fps + proper drop-frame detection, shell-safe plan output; streams never VFR-re-encoded; gap list in the sidecar; docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:17:26 -05:00
parent 3fcb8fbe99
commit f36487d4b5
4 changed files with 41 additions and 8 deletions

View File

@@ -61,7 +61,9 @@ probe() {
med=d[int(n/2)]; if (med<=0) { print "cfr"; exit }
bad=0; for(i=0;i<n;i++){ x=d[i]-med; if(x<0)x=-x; if (x > med*0.25) bad++ }
print (bad > n*0.02) ? "vfr" : "cfr" }')
[[ $vfr == vfr ]] && { echo "encode vfr"; return; }
# streams: gaps from dropped frames are legitimate and re-encoding a 3 h file is hours of CPU — never VFR-encode
# them (the timecode sidecar lists the gaps instead). Clips (short, often genuinely VFR) still get CFR.
if [[ $vfr == vfr && $f != "$ROOT/streams/"* ]]; then echo "encode vfr"; return; fi
local moov mdat; moov=$(grep -o '[0-9]*:moov' <<<"$head" | cut -d: -f1 | head -1); mdat=$(grep -o '[0-9]*:mdat' <<<"$head" | cut -d: -f1 | head -1)
if [[ -z $moov || ( -n $mdat && $mdat -lt $moov ) ]]; then echo "remux index-last"; return; fi
echo "ok"

View File

@@ -68,10 +68,15 @@ def decode_band_row(row: bytes, w: int):
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],
"stream=width,height,avg_frame_rate,r_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)
# nominal rate (r_frame_rate) — avg_frame_rate is skewed by dropped frames / gaps
def rate(x):
try: num, den = x.split("/"); return float(num) / float(den or 1)
except Exception: return 0.0
fps = rate(st.get("r_frame_rate", "")) or rate(st.get("avg_frame_rate", "")) or 60.0
if fps > 240: fps = rate(st.get("avg_frame_rate", "")) or 60.0 # bogus r_frame_rate on some files
return int(st["width"]), int(st["height"]), fps, float(j["format"]["duration"])
def sample_window(path, w, h, fps, start_s, dur_s):
@@ -120,15 +125,30 @@ def analyse(path, win_s=4.0):
return {"source": "strip", "t0_ms": t0, "drift_ms": drift, "fps": fps, "width": w, "height": h,
"duration_s": dur, "samples": samples}
def find_gaps(path, fps, max_list=50):
"""Holes in the video timeline (dropped frames): deltas > 1.5 frame periods. Demux only, fast."""
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time",
"-of", "csv=p=0", path], capture_output=True, text=True).stdout
pts = sorted(float(x.strip(",").split(",")[0]) for x in out.split() if x.strip(",") not in ("", "N/A"))
period = 1.0 / (fps or 60.0)
gaps, total = [], 0.0
for a, b in zip(pts, pts[1:]):
d = b - a
if d > period * 1.5:
total += d - period
if len(gaps) < max_list: gaps.append({"at_s": round(a, 3), "missing_ms": int(round((d - period) * 1000))})
return {"count": sum(1 for a, b in zip(pts, pts[1:]) if b - a > period * 1.5), "total_missing_ms": int(round(total * 1000)), "first": gaps}
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)
nominal = max(1, round(fps))
ff = min(nominal - 1, int(frac * fps))
sep = ";" if abs(fps - nominal) > 0.01 else ":"
drop = any(abs(fps - r) < 0.05 for r in (29.97, 59.94, 23.976)) # true NTSC rates only
sep = ";" if drop else ":"
return f"{local:%H:%M:%S}{sep}{ff:02d}", local
def plan(path, arrival_ms=None, tz="America/New_York"):
@@ -142,14 +162,18 @@ def plan(path, arrival_ms=None, tz="America/New_York"):
else:
t0 = None; src = "none"
out = {"source": src, "fps": r.get("fps"), "drift_ms": r.get("drift_ms", 0), "samples": r.get("samples", [])}
try:
out["gaps"] = find_gaps(path, r.get("fps") or 60.0)
except Exception:
pass
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(f"TC_TIMECODE='{tc}'") # quoted: drop-frame uses ';' which the shell would split on
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