Guide
A centre crop from 16:9 to 9:16 misses the speaker's face nearly 9 times in 10. OpenCV YuNet and an ffmpeg crop path keep the face in frame, or clipsdk does it in one API call at $0.03 per source minute.
Face tracking crop: 1920x1080 in, 1080x1920 out, with YuNet and ffmpeg
Turning 16:9 into 9:16 throws away two thirds of the width, so the crop has to follow the face or the speaker leaves the frame.
GuidesUpdated
Or one API call
$0.03per source minute
- Every clip included, up to 1080p60 and 20 Mb/s
- $1 free to start, 33 minutes of video
- Prepaid credit, no subscription
Ways to keep a face in a 9:16 crop
Measured on the first 56 seconds of the lesson, the range the API picked as its top clip.
| Approach | How it places the crop | On this lesson | Cost |
|---|---|---|---|
| Centre crop | Fixed in the middle of the frame | Face centre outside the crop nearly 9 times in 10 | Free |
YuNet and ffmpeg sendcmd (this guide) | Biggest face, a dead zone, capped speed | A timed crop position 5 times a second; shots with no face keep the last spot | Free, on a CPU |
clipsdk reframe: "auto" | Shots split at cuts, active speaker, whole frame when no face | 2 clips at 1080x1920 | $0.03 per source minute |
Build a face tracking crop
Four steps on a CPU: get the video and the model, find the face, turn face positions into a crop path, and render.
Get the lesson and the face model
The lesson is a 1920x1080 NASA video. YuNet ships in OpenCV as
FaceDetectorYN, and the model is a small ONNX file from opencv_zoo.terminal curl -O https://api.clipsdk.com/f/lib/demo/thermal-energy-lesson.mp4 curl -LO https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx pip install "opencv-python-headless==5.0.0.93"Find the face 5 times a second
Faces are found on a 640x360 copy, which is plenty for a face this size. We keep the biggest face's centre and scale it back to 1920 px. Samples with no face are B-roll with titles over it, like the sun, a snowy river and Earth from orbit.
faces.py import json import cv2 CLIP_END = 55.78 detector = cv2.FaceDetectorYN.create("face_detection_yunet_2023mar.onnx", "", (640, 360), 0.6, 0.3, 16) video = cv2.VideoCapture("thermal-energy-lesson.mp4") fps = video.get(cv2.CAP_PROP_FPS) step = round(fps / 5) samples = [] frame_no = 0 while frame_no / fps < CLIP_END: ok, frame = video.read() if not ok: break if frame_no % step == 0: _, faces = detector.detect(cv2.resize(frame, (640, 360))) cx = None if faces is not None: x, y, w, h = max(faces, key=lambda f: f[2] * f[3])[:4] cx = float(x + w / 2) * frame.shape[1] / 640 samples.append({"t": round(frame_no / fps, 2), "cx": cx}) frame_no += 1 json.dump(samples, open("faces.json", "w")) print(len(samples), "samples,", sum(s["cx"] is not None for s in samples), "with a face") # $ python faces.py # 279 samples, 171 with a faceTurn face positions into a crop path
A 9:16 crop of a 1080 px tall frame is 608 px wide. We snap to the face on the first sample and whenever the target jumps by more than half a crop, which is what a cut looks like. Smaller moves inside a dead zone of 12% of the crop width are ignored, and larger ones ease at up to half a crop width per second. Each sample becomes one ffmpeg
sendcmdline.crop_path.py import json SOURCE_W, CROP_W = 1920, 608 DEAD_ZONE = 0.12 * CROP_W MAX_MOVE = 0.5 * CROP_W * 0.2 samples = json.load(open("faces.json")) centre_x = (SOURCE_W - CROP_W) / 2 x, lines, missed = None, [], 0 for s in samples: if s["cx"] is None: continue if not centre_x < s["cx"] < centre_x + CROP_W: missed += 1 target = min(max(s["cx"] - CROP_W / 2, 0), SOURCE_W - CROP_W) # Snap on the first face and on a jump of half a crop (a cut), else ease past the dead zone if x is None or abs(target - x) > CROP_W / 2: x = target elif abs(target - x) > DEAD_ZONE: move = min(abs(target - x) - DEAD_ZONE, MAX_MOVE) x += move if target > x else -move lines.append(f'{s["t"]:.2f} crop x {round(x)};') open("crop.cmd", "w").write("\n".join(lines) + "\n") print(len(lines), "commands, a centre crop misses the face in", missed) print("\n".join(lines[:4])) # $ python crop_path.py # 171 commands, a centre crop misses the face in 149 # 0.20 crop x 306; # 0.40 crop x 306; # 0.60 crop x 306; # 0.80 crop x 306;Render the vertical clip
sendcmdreads the file and changes the crop's x position at each time. The crop is scaled to 1080x1920 for a vertical post.terminal ffmpeg -t 55.78 -i thermal-energy-lesson.mp4 -vf "sendcmd=f=crop.cmd,crop=608:1080:656:0,scale=1080:1920" -c:v libx264 -preset veryfast -c:a aac tracked.mp4 ffprobe -v error -select_streams v -show_entries stream=width,height:format=duration -of compact=p=0 tracked.mp4 # output: # width=1080|height=1920 # duration=55.800000
Or send one request
Send the lesson with aspect: "9:16" and reframe: "auto". The engine runs YuNet on frames decoded at 640 px and finds faces 5 times a second. Its camera ignores moves inside a dead zone of 12% of the crop width, then eases at no more than half a crop width per second, and it cuts hard on a scene cut or a speaker switch.
It returned 2 clips from the 4-minute lesson. Clip 1 covers the first 56 seconds, the range we cropped by hand above, as a 1080x1920 MP4 with captions. At $0.03 per source minute, billed per second, the lesson, just over 4 minutes, costs about $0.13. See pricing.
curl https://api.clipsdk.com/v1/clips \
-H "Authorization: Bearer $CLIPSDK_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://api.clipsdk.com/f/lib/demo/thermal-energy-lesson.mp4",
"clips": 2,
"aspect": "9:16",
"reframe": "auto"
}'Where this script falls short
Shots with no face keep the last crop. In our render that cut off text on title cards and B-roll, which a viewer notices at once. The clipsdk engine switches a shot to a fit layout that shows the whole frame when a face shows up in under 30% of its sampled frames.
A jump of half a crop only catches cuts where the face moves far. When the face lands less than half a crop away, the crop eases into the new shot, a pan that never happened. The engine splits each clip into shots at scene cuts first and never pans across one; the scene cut guide shows how to find them.
With two people in frame, the biggest face wins, and that is often not the one talking. The active speaker guide adds a mouth-motion score for that.
What it costs to run yourself
Everything here is free to run on a CPU. The cost is your time: no-face shots, cuts, two speakers, captions and the render settings for each platform all need code of their own, and so does every source that breaks one of your rules.
Questions, answered
How do I crop a 16:9 video to 9:16 around a face with ffmpeg?
Find the face with a detector like YuNet, turn its x position into timed crop commands, and feed them to ffmpeg's crop filter through sendcmd. A 1920x1080 frame gives a 608x1080 crop, which scales to 1080x1920.
Why does my tracked crop shake?
Detections move by a few pixels from frame to frame. Ignore moves inside a dead zone, here 12% of the crop width, and cap the speed so the crop eases instead of jumping.
What should the crop do when there is no face?
Holding the last position cuts off text and B-roll. Showing the whole frame is safer; clipsdk does that for shots with a face in under 30% of sampled frames.
Which YuNet model file should I use?
We used face_detection_yunet_2023mar.onnx. The clipsdk engine runs the same 2023mar model, converted to take any input size. opencv_zoo now also ships a 2026may file with a dynamic input shape; in our tests on OpenCV 5 both returned the same boxes.
Keep reading
- Speaker reframe: 16:9 to 9:16 that follows whoever is talkingReframe wide video to 9:16 (1080x1920), 1:1, 4:5 or 16:9. Auto mode tracks faces and cuts to whoever is talking; center and none are one field away. $0.03 per source minute.
- Cut every lesson and webinar into teaser clipsUpload a lesson or webinar and get up to 20 ranked teaser clips with minimal captions and SRT files. A 45 minute lesson costs ~$1.35 and a 90 minute webinar ~$2.70.
- Active speaker detection in PythonFind who is talking in a two-person shot on a CPU with YuNet landmarks and mouth motion, then check it against the transcript.
- Scene cut detection for clipsFind hard cuts with PySceneDetect and ffmpeg scdet, split a clip into shots, and see why a vertical reframe must restart at each one.
- Find viral moments with an LLMA tested prompt that makes an LLM pick clip-worthy moments from a transcript by hook, complete thought and payoff, plus the checks its answers need.
- TikTok safe zonesTikTok, Reels and YouTube safe zones for captions in pixels at 1080x1920, measured from TikTok's own template, with a script that checks your clips.
Sources
- YuNet in opencv_zoo, checked 7 November 2026
- OpenCV FaceDetectorYN docs, checked 7 November 2026
- opencv-python-headless on PyPI, checked 7 November 2026
- FFmpeg sendcmd filter, checked 7 November 2026
Start free
Your back catalog is full of shorts
Your first $1 is free: 33 minutes of video, every clip included up to 1080p60 and 20 Mb/s.
