|
| 1 | +# Comparative analysis: `kamilstanuch/Autocrop-vertical` → ClippyMe |
| 2 | + |
| 3 | +Date: 2026-06-17 |
| 4 | +External repo: <https://github.com/kamilstanuch/Autocrop-vertical> @ `main` |
| 5 | +Size: single 710-LOC `main.py` + `requirements.txt` + README (v1.4.1). |
| 6 | +Sixth external study, after `gauravzazz/smart-reframe`, |
| 7 | +`KazKozDev/auto-vertical-reframe`, `obi19999/smart-video-reframe`, |
| 8 | +`mfahsold/montage-ai`, and `aregrid/frame`. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## 0. Verdict up front |
| 13 | + |
| 14 | +Unlike the fifth study (`aregrid/frame`, vaporware), this is **real, mature, |
| 15 | +single-file code** in the same domain: horizontal → vertical social-media |
| 16 | +cropping (PySceneDetect → YOLOv8 person detection → per-scene TRACK/LETTERBOX → |
| 17 | +OpenCV frame pipe → FFmpeg encode). Its **reframe algorithm is simpler than |
| 18 | +ClippyMe's** (static per-scene center-crop on a person box; no camera smoothing, |
| 19 | +no active-speaker selection, no continuous zoom, no global trajectory pass) — so |
| 20 | +there is nothing to port on the reframe-*math* axis where ClippyMe already runs |
| 21 | +ahead (`reframe_ops.py` + five prior studies). |
| 22 | + |
| 23 | +Its real value is the **production-hardening layer** the changelog was written |
| 24 | +around: A/V-sync correctness on messy real-world inputs (YouTube downloads, phone |
| 25 | +VFR). That layer maps onto concrete, previously-unhandled gaps in ClippyMe's |
| 26 | +clip/reframe render path, and is what this study ports. |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## 1. Architecture comparison |
| 31 | + |
| 32 | +| Aspect | Autocrop-vertical | ClippyMe | Takeaway | |
| 33 | +|---|---|---|---| |
| 34 | +| Scene detection | PySceneDetect `ContentDetector`, `--frame-skip`/`--downscale` | PySceneDetect (`scene_detection.py`) | Parity. | |
| 35 | +| Subject detect | YOLOv8n person + Haar-cascade face (middle frame only) | YOLOv8n + MediaPipe FaceMesh + MAR active-speaker (per frame) | ClippyMe richer. | |
| 36 | +| Crop decision | `decide_cropping_strategy`: 0 people→LETTERBOX, 1→track box, group→TRACK if group-width < crop-width else LETTERBOX | `analyze_scenes_strategy` → TRACK/WIDE/GENERAL/DISABLED | Comparable intent; ClippyMe adds WIDE multi-speaker. | |
| 37 | +| Camera | **static** center-crop per scene (no motion) | `SmoothedCameraman`: EMA/1€/spring smoothing, continuous zoom, lost-subject drift | ClippyMe far ahead. | |
| 38 | +| **VFR handling** | `is_variable_frame_rate` (ffprobe r vs avg rate) → re-mux `-vsync cfr` | **none** | **Gap. Ported.** | |
| 39 | +| **Audio start_time** | `get_stream_start_time` → `-ss` trims audio lead-in | **none** | **Gap. Ported.** | |
| 40 | +| **Corrupt-frame** | try/except → duplicate last good frame | loop aborts on exception | **Gap. Ported.** | |
| 41 | +| **Output pix_fmt** | explicit `yuv420p` | only on *later* passes; reframe core + cut omit it | **Gap. Ported.** | |
| 42 | +| fps source | OpenCV (same backend that reads frames) + `-vsync cfr` | PySceneDetect for `-r`, OpenCV elsewhere (mismatch risk) | Mitigated by CFR + VFR ports. | |
| 43 | +| HW encoder | `--encoder hw` (VideoToolbox/NVENC) + fallback | libx264 only | Deferred (see §3). | |
| 44 | +| Quality presets | `--quality fast/balanced/high` + CRF/preset overrides | fixed per stage | Deferred (off product axis). | |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## 2. Prioritised improvement list |
| 49 | + |
| 50 | +| Pri | Improvement | Status | Notes | |
| 51 | +|---|---|---|---| |
| 52 | +| **High** | Audio `start_time` offset compensation | ✅ implemented | Classic yt-dlp A/V desync; YouTube is ClippyMe's primary input. `audio_sync_seek_args` + `probe_stream_start_time`, wired into reframe Step 5. | |
| 53 | +| **High** | VFR → CFR pre-normalization | ✅ implemented | Phone/odd-timebase sources. `probe_is_variable_frame_rate` gate + re-mux in reframe; CFR inputs (the normal clip slices) untouched. | |
| 54 | +| **Medium** | `-pix_fmt yuv420p` on the two bare libx264 encoders | ✅ implemented | reframe raw-frame encoder + main.py source-slice cut; guarantees universal decode even when later 420p passes are skipped. | |
| 55 | +| **Medium** | Corrupt/failed-frame resilience | ✅ implemented | try/except in the reframe render loop → duplicate last good frame; preserves frame count → no A/V drift. | |
| 56 | +| **Medium** | `-vsync cfr` on reframe encoder + cut | ✅ implemented | Locks constant output rate; pairs with the VFR fix. | |
| 57 | +| Low | fps cross-check (cv2 ↔ PySceneDetect) | ⏸ deferred | Subsumed: VFR normalize + CFR output remove the drift this guarded against. A hard assert could be added later. | |
| 58 | +| Low | Hardware encoder (NVENC/VideoToolbox) | ⏸ deferred | ClippyMe encodes in a Linux/Docker container where libx264 is the portable choice; HW detect adds surface for marginal CPU savings off the product axis. Documented, not wired. | |
| 59 | +| Low | `--quality`/`--ratio`/`--plan-only` CLI knobs | ⏸ deferred | ClippyMe's ratio/quality are driven by the dashboard + per-stage tuning, not CLI flags; not a fit. | |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +## 3. What was implemented |
| 64 | + |
| 65 | +New module **`src/clippyme/pipeline/media_probe.py`** (pure helpers + thin |
| 66 | +ffprobe wrappers, **no cv2** → host-importable), following the established |
| 67 | +`reframe_ops.py` "pure logic in a testable module, thin glue in the cv2 file" |
| 68 | +pattern: |
| 69 | + |
| 70 | +- `parse_frame_rate(str) -> float` — "30000/1001" → 29.97; malformed/zero → 0.0. |
| 71 | +- `is_vfr(r_rate, avg_rate, threshold=0.5) -> bool` — nominal vs average gap. |
| 72 | +- `parse_start_time(str) -> float` — ffprobe `start_time`; "N/A"/garbage → 0.0. |
| 73 | +- `audio_sync_seek_args(start, min_offset=0.05) -> list[str]` — `['-ss', …]` or `[]`. |
| 74 | +- `probe_stream_start_time` / `probe_is_variable_frame_rate` — ffprobe wrappers |
| 75 | + that **never raise** (missing ffprobe / odd file → safe default). |
| 76 | + |
| 77 | +Wiring (glue, in the integration-tested cv2 files): |
| 78 | + |
| 79 | +- **`reframe.py`** — (a) VFR detection + `-vsync cfr` re-mux pre-step (gated on |
| 80 | + `probe_is_variable_frame_rate`, so CFR inputs are byte-identical); (b) |
| 81 | + `-pix_fmt yuv420p` + `-vsync cfr` on the raw-frame encoder; (c) try/except |
| 82 | + around per-frame strategy → duplicate last good frame (`dropped_frames` |
| 83 | + reported); (d) audio extracted with `audio_sync_seek_args(start_time)`; |
| 84 | + (e) `-shortest` on the final mux; (f) `temp_cfr_input` cleanup. |
| 85 | +- **`main.py`** — `-pix_fmt yuv420p` + `-vsync cfr` on the persisted source-slice |
| 86 | + cut, so the slice is universally decodable and CFR before reframe ever sees it. |
| 87 | + |
| 88 | +Tests: **`tests/pipeline/test_media_probe.py`** — 31 host (non-integration) |
| 89 | +cases covering frame-rate parsing, VFR thresholds, start_time parsing, seek-arg |
| 90 | +no-op boundaries, and the never-raise contract. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## 4. Conflicts & resolutions |
| 95 | + |
| 96 | +- **"Proven single-pass path must stay byte-identical" (CLAUDE.md).** Every port |
| 97 | + is a *no-op on the common case*: `audio_sync_seek_args` returns `[]` for a |
| 98 | + zero start_time, VFR normalization only fires when detection is confident, and |
| 99 | + the corrupt-frame guard only diverges on an exception that previously aborted |
| 100 | + the run. The normal pipeline feeds already-re-encoded CFR, zero-start clip |
| 101 | + slices, so its bytes are unchanged — confirmed by the integration suite still |
| 102 | + passing (13/13). |
| 103 | +- **"Don't rewrite; keep the style" (instruction).** No reframe logic was |
| 104 | + rewritten; the static-crop algorithm was *not* adopted (ClippyMe's is better). |
| 105 | + Only the orthogonal robustness layer was added, in the same inline-ffmpeg + |
| 106 | + pure-helper-module style already used across the pipeline. |
| 107 | +- **fps-source mismatch (concern #3).** Rather than re-plumb every fps read, the |
| 108 | + VFR normalize + CFR-locked output neutralize the drift the mismatch could |
| 109 | + cause. Noted as a deferred hardening (an explicit cv2-vs-PySceneDetect assert). |
| 110 | +- **`_render_global_smooth` opt-in path** shares the same single-write model, so |
| 111 | + the corrupt-frame guard is not applied there yet — that path is opt-in and |
| 112 | + off by default; noted for a follow-up if it graduates. |
| 113 | +- **Heavy ports skipped.** HW encoder, quality/ratio CLI flags are real but off |
| 114 | + ClippyMe's product axis (dashboard-driven, containerized libx264) — catalogued |
| 115 | + as deferred, honouring "Non riscrivere tutto." |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## 5. Learnings & how they were applied |
| 120 | + |
| 121 | +1. **A simpler upstream can still be a net contribution — via its hardening, not |
| 122 | + its core.** Autocrop's *algorithm* is behind ClippyMe's, but its changelog is |
| 123 | + a checklist of real-world A/V-sync bugs (VFR drift, start_time desync, |
| 124 | + dropped-frame drift) that a more sophisticated reframe core never bothered to |
| 125 | + handle. Value lived in the boring layer. *Applied:* ported the robustness, |
| 126 | + ignored the algorithm. |
| 127 | +2. **Pure-vs-glue split makes "untestable" ffmpeg logic testable.** The decision |
| 128 | + content (VFR threshold, seek-arg boundaries, rate parsing) was extracted into |
| 129 | + a cv2-free module and host-unit-tested (31 cases), while only the ffmpeg |
| 130 | + string-building stayed in the integration-only file — same discipline as |
| 131 | + `reframe_ops.py`. *Applied:* `media_probe.py`. |
| 132 | +3. **Port as no-ops on the happy path.** Each fix is gated so the proven path is |
| 133 | + byte-identical and only pathological inputs take the new branch. This is what |
| 134 | + let a change to the integration render loop ship without re-baselining the |
| 135 | + golden output. *Applied:* every wiring point above. |
| 136 | + |
| 137 | +--- |
| 138 | + |
| 139 | +## 6. Verification |
| 140 | + |
| 141 | +- Host pure-helper suite: `pytest tests/pipeline/test_media_probe.py` → **31 passed**. |
| 142 | +- Full host (non-integration) suite: `pytest -m "not integration"` → **279 passed, |
| 143 | + 2 skipped** (248 prior baseline + 31 new) — no regression. |
| 144 | +- Integration suite in Docker: |
| 145 | + `docker compose run --rm -u root backend sh -lc "pip install -q pytest && pytest -m integration"` |
| 146 | + → **13 passed, 290 deselected** — reframe.py imports (cv2/mediapipe) and the |
| 147 | + reframe render path are unaffected by the wiring. |
| 148 | +- `py_compile` on `reframe.py`, `main.py`, `media_probe.py` → clean. |
0 commit comments