#!/usr/bin/env python3 """Check an encoded video for what stills miss: cuts, one-frame blinks, and text that shimmers while it should hold still. python3 qa-video.py film.mp4 hard cuts (with times) and blink candidates python3 qa-video.py film.mp4 --hold 1680 1790 360 420 900 300 largest frame-to-frame change inside region x,y,w,h over frames a..b (60 fps frame numbers). Held text should change by ~2-4 levels (encoder noise); 20+ means it moves or shimmers. python3 qa-video.py film.mp4 --where 735 748 bounding box of what changed in each frame of a range Compare the hard-cut list with the cuts you planned; anything else hard is a bug (a scene popping instead of animating out). Blink candidates are frames where something changes and reverts: look at them, though fast deliberate flips (cycling words, caption streams) show up too. """ import argparse, glob, re, subprocess, tempfile from common import ffmpeg ap = argparse.ArgumentParser() ap.add_argument("video"); ap.add_argument("--hold", nargs=6, type=int); ap.add_argument("--where", nargs=2, type=int) ap.add_argument("--fps", type=float, default=60) a = ap.parse_args(); FF = ffmpeg() def frames(a0, b0, extra=""): from PIL import Image d = tempfile.mkdtemp() subprocess.run([FF, "-v", "error", "-i", a.video, "-vf", f"select='between(n,{a0},{b0})'{extra},format=gray", "-vsync", "0", f"{d}/f%05d.png"], check=True) return [Image.open(f) for f in sorted(glob.glob(f"{d}/f*.png"))] if a.hold: from PIL import ImageChops f0, f1, x, y, w, h = a.hold ims = frames(f0, f1, f",crop={w}:{h}:{x}:{y}"); worst = (0, None) for k in range(1, len(ims)): m = ImageChops.difference(ims[k], ims[k - 1]).getextrema()[1] if m > worst[0]: worst = (m, f0 + k) print(f"frames {f0}-{f1}, region {x},{y} {w}x{h}: largest change {worst[0]} at frame {worst[1]} ({(worst[1] or 0) / a.fps:.3f}s)") elif a.where: from PIL import ImageChops ims = frames(*a.where) for k in range(1, len(ims)): bb = ImageChops.difference(ims[k], ims[k - 1]).point(lambda v: 255 if v > 24 else 0).getbbox() print(a.where[0] + k, f"{(a.where[0] + k) / a.fps:.3f}s", bb) else: log = tempfile.mktemp(suffix=".txt") subprocess.run([FF, "-v", "error", "-i", a.video, "-vf", f"scale=480:270,signalstats,metadata=print:key=lavfi.signalstats.YDIF:file={log}", "-f", "null", "-"], check=True) v = [float(x) for x in re.findall(r"YDIF=([\d.]+)", open(log).read())] print(f"{len(v)} frames; hard cuts at", [round(i / a.fps, 2) for i, d in enumerate(v) if d > 60]) print("blink candidates:", [round(i / a.fps, 3) for i in range(1, len(v) - 2) if v[i] > 4 and v[i + 1] > 4 and v[i] > 3 * max(v[i - 1], 0.3) and v[i + 1] > 3 * max(v[i + 2], 0.3)])