Tennis Rally Cut (tennis-rally-cut)
Distilled from a real editing session on 2026-11-08: two wall-drill videos, cut into 14 rallies. Core idea: Use sound to find rallies, use video to verify.
What the User Wants (Hard Rules)
- A rally = from the first hit until the ball is dead. The entire rally goes into one clip.
- Never split a rally. If someone hits 10 consecutive shots against a wall, that's one clip; do not break it into several 2- or 3-shot pieces.
- Ball retrieval, walking, pauses, picking up a phone, grabbing equipment — all removed.
- Original videos remain untouched. Output goes into a
剪辑/folder in the same directory as the original video.
Why Use Sound, Not Visuals
- Every hit produces two crisp sounds: "racket hitting ball" and "ball hitting wall/ground." No such sounds during ball pickup or walking.
- Visual motion doesn't work: walking and reaching for the ball create more motion than playing.
- Video is only used for spot-checking and verification.
Tools
Script: scripts/rally_cut.py. Only depends on ffmpeg, ffprobe, and numpy.
| Subcommand | Purpose |
|---|---|
probe | Check resolution, bitrate, orientation; warn if compressed by chat apps |
analyze | Detect hit sounds, stitch into rallies, write <name>.segments.json |
sheets | Generate frame sheets (start and end frame of each rally) for visual review |
cut | Cut by rally into separate mp4s, write 片段清单.txt |
align | When a high-quality original is available, compute time offset between old and new versions |
Hereafter, RC refers to the absolute path of the script. Intermediate files go into a session temp directory, not into the user's video directory.
Workflow
1. Check Quality First
python3 -I $RC probe <video...>
- If it says "suspected compression by chat app," inform the user first. Editing itself hardly reduces quality; if the original is blurry, the output will be too.
- Videos from WeChat often have the original in the WeChat cache, with filename suffix
_raw:~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/<account>/msg/video/<yyyy-mm>/<name>_raw.mp4The user must tap "View Original" in WeChat before it downloads. Usemdfindto search by date added:mdfind -onlyin ~ 'kMDItemContentTypeTree == "public.movie" && kMDItemDateAdded >= $time.iso(<date>)' - Once the high-quality original is found, use
alignto confirm offset is 0, then the computed cut points can be reused directly.
2. Find Rallies
python3 -I $RC analyze <video...> --work <temp_dir>
It prints a timeline of hits per second and a rally list. Default parameters:
| Parameter | Default | Meaning |
|---|---|---|
--k | 2.5 | How many times louder than background to count as a hit. Increase in noisy environments |
--gap | 6 | If two hits are more than 6 seconds apart, it's the end of a rally. This is key to not splitting rallies. |
--min-hits | 6 | Sequences with fewer than 6 hits are discarded as noise (footsteps, phone pickups) |
--pre / --post | 1 / 1.5 | Seconds to pad before and after each rally to ensure swing and landing are complete |
Trade-off for --gap:
- 4 seconds would split scenarios where "ball is dead but player restarts within a few seconds" into two clips.
- 6 seconds keeps them together. The user requires not splitting, so default is 6.
- Only change to 4 if the user explicitly says "each restart is a separate clip."
3. Visual Verification (Must Not Skip)
python3 -I $RC sheets <video> --segments <json> --work <temp_dir>/sheets
Look at each frame sheet, focus on:
- The last rally of the video: often ends with someone walking toward the camera or picking up a phone, whose sounds can be mistaken for hits. If you see this, change
endto about 1.5 seconds after the last real hit. - Short rallies with only a few hits: could be noise. Delete if nobody is swinging in the frame.
- Gaps of about 5 seconds between rallies: confirm they are dead-ball restarts, not missed detections.
Edit the segments array in segments.json directly: change start/end, or remove the entire segment.
4. Cut
python3 -I $RC cut <video> --segments <json> --out <video_dir>/剪辑 --label A
- Multiple source videos use different
--label(A, B…), file names like视频A_03_2m54s-3m22s.mp4, where time is from the original. - Must re-encode (default H.264, CRF 17). Original keyframes are often 2–3 seconds apart; direct copy would misalign cuts.
- For portrait videos, ffmpeg will automatically handle rotation; output will be upright.
- If the target directory already has old output, rename them to
剪辑_old_versionas backup — do not overwrite or delete. - 1080p 60fps encoding is slow; run it in the background.
5. Verify, Then Report
- Use ffprobe to check each output's duration, resolution, and frame rate.
- Extract a frame from one or two outputs: check orientation and clarity.
- Report: number of clips, duration range per clip, output directory (provide a
file://link that Finder can open; percent-encode Chinese paths). - Flag any questionable clips (e.g., very short rallies with few hits) so the user can decide whether to delete them.
- State that you only spot-checked the visuals, not watched every clip in full.
Pitfalls Encountered
| Pitfall | Consequence | Fix |
|---|---|---|
Calling ffmpeg in a loop without -nostdin | ffmpeg ate subsequent lines from the pipeline, causing wrong cut points and crashes | Script now includes it |
| Time calculated as "frames × 10ms" | One frame is actually 220 samples; drifts by 1 second over 7 minutes | Script converts by samples |
| Using visual motion to detect playing | Picking up the ball creates more motion than playing | Use sound only |
| Using a compressed forwarded video | Output is blurry; user thinks editing ruined it | Step 1: probe first |