#!/usr/bin/env python3
"""reframe.py — auto-reframe: find where the speaker's face sits across a clip and
return the ffmpeg crop (for a target aspect) centered on them. Replaces hand-picking
the crop x-offset. Lean face-track (OpenCV Haar) — the mechanism ClipsAI's resize
uses (facenet), minus the torch/pyannote multi-speaker stack. Single-speaker friendly.
Runs in tools/reframe-venv (python 3.11 + opencv). Prints JSON:
{"crop": "cropW:cropH:x:y", "faces": N, "cx": .., "cy": .., "source": "WxH"}
`crop` is a drop-in for ffmpeg `crop=<crop>,scale=...`. Falls back to a centered crop
(faces:0) so a render never breaks.
"""
import argparse
import json
import sys
import cv2
def _even(n):
n = int(round(n))
return n - (n % 2)
def reframe(video, start, end, aspect=(9, 16), samples=24):
cap = cv2.VideoCapture(video)
if not cap.isOpened():
raise SystemExit(json.dumps({"error": "cannot open video"}))
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
dur = max(0.1, float(end) - float(start))
casc = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
centers = [] # (cx, cy, area) of the largest face per sampled frame
for i in range(samples):
t = float(start) + dur * (i + 0.5) / samples
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000.0)
ok, frame = cap.read()
if not ok:
continue
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = casc.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=6,
minSize=(max(40, W // 18), max(40, H // 18)))
if len(faces):
x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) # biggest face
centers.append((x + w / 2, y + h / 2, w * h))
cap.release()
# target crop rect (largest rect of the aspect that fits the source)
r = aspect[0] / aspect[1]
cropW, cropH = (_even(H * r), H) if H * r <= W else (W, _even(W / r))
if centers:
centers.sort(key=lambda c: c[0]) # median-x is robust to a stray detection
cx = centers[len(centers) // 2][0]
cy = sum(c[1] for c in centers) / len(centers)
else:
cx, cy = W / 2, H / 2 # no face → center (never breaks)
x = _even(min(max(cx - cropW / 2, 0), W - cropW))
y = _even(min(max(cy - cropH / 2, 0), H - cropH))
return {"crop": f"{cropW}:{cropH}:{x}:{y}", "faces": len(centers),
"cx": round(cx), "cy": round(cy), "source": f"{W}x{H}"}
if __name__ == "__main__":
a = argparse.ArgumentParser()
a.add_argument("--video", required=True)
a.add_argument("--start", type=float, default=0)
a.add_argument("--end", type=float, default=30)
a.add_argument("--aspect", default="9:16")
a.add_argument("--samples", type=int, default=24)
p = a.parse_args()
asp = tuple(int(x) for x in p.aspect.split(":"))
print(json.dumps(reframe(p.video, p.start, p.end, asp, p.samples)))