Files
8-cut/docs/plans/2026-04-06-portrait-crop-implementation.md

525 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Portrait Crop Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a portrait crop mode that crops a vertical window from the landscape source, with a ratio dropdown (Off/9:16/4:5/1:1), a crop bar for visual positioning, and video click-to-center.
**Architecture:** A module-level `_portrait_crop_filter` helper builds the ffmpeg `crop=` expression; `build_ffmpeg_command` chains it with the existing scale filter when both are active. `CropBarWidget` is a new thin `QWidget` that paints and repositions the crop window. `MpvWidget` gains a `crop_clicked` signal. `MainWindow` wires everything together and persists state via `QSettings`.
**Tech Stack:** PyQt6 (`QComboBox`, `QPainter`, `pyqtSignal`), ffmpeg `crop` filter expression syntax. No new dependencies.
---
### Task 1: `build_ffmpeg_command` portrait crop (TDD)
**Files:**
- Modify: `main.py` — add `_RATIOS`, `_portrait_crop_filter`, update `build_ffmpeg_command`
- Modify: `tests/test_utils.py` — add 3 new tests
**Step 1: Write failing tests**
Add to the end of `tests/test_utils.py`:
```python
def test_ffmpeg_command_portrait_only():
cmd = build_ffmpeg_command(
"/in/video.mp4", 0.0, "/out/clip.mp4",
portrait_ratio="9:16", crop_center=0.5,
)
assert "-vf" in cmd
vf = cmd[cmd.index("-vf") + 1]
assert "crop" in vf
assert "9" in vf
assert "scale" not in vf
assert cmd[-1] == "/out/clip.mp4"
def test_ffmpeg_command_portrait_and_resize():
cmd = build_ffmpeg_command(
"/in/video.mp4", 0.0, "/out/clip.mp4",
short_side=256, portrait_ratio="9:16", crop_center=0.5,
)
assert "-vf" in cmd
vf = cmd[cmd.index("-vf") + 1]
assert "crop" in vf
assert "scale" in vf
# crop must come before scale
assert vf.index("crop") < vf.index("scale")
assert cmd[-1] == "/out/clip.mp4"
def test_ffmpeg_command_portrait_off():
cmd = build_ffmpeg_command("/in/video.mp4", 0.0, "/out/clip.mp4")
assert "-vf" not in cmd
```
**Step 2: Run to verify they fail**
```bash
cd /media/p5/8-cut && python -m pytest tests/test_utils.py -k "portrait" -v 2>&1 | tail -20
```
Expected: all 3 fail — `portrait_ratio` param doesn't exist yet.
**Step 3: Add `_RATIOS` and `_portrait_crop_filter` to `main.py`**
Add after `build_ffmpeg_command` (after line 52, before `_normalize_filename`):
```python
_RATIOS: dict[str, tuple[int, int]] = {
"9:16": (9, 16),
"4:5": (4, 5),
"1:1": (1, 1),
}
def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
"""Return an ffmpeg crop= filter expression for the given portrait ratio.
Uses ffmpeg expression syntax so source dimensions are resolved at runtime.
Commas inside min()/max() are escaped with \\, to prevent ffmpeg's
filtergraph parser from treating them as filter-chain separators.
"""
num, den = _RATIOS[ratio]
cw = f"ih*{num}/{den}"
# Clamp x so the crop window never exceeds frame bounds.
x = f"max(0\\,min((iw-{cw})*{crop_center}\\,iw-{cw}))"
return f"crop={cw}:ih:{x}:0"
```
**Step 4: Update `build_ffmpeg_command`**
Replace the function signature and body (lines 33-52):
```python
def build_ffmpeg_command(
input_path: str, start: float, output_path: str,
short_side: int | None = None,
portrait_ratio: str | None = None,
crop_center: float = 0.5,
) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
cmd = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
]
filters: list[str] = []
if portrait_ratio is not None:
filters.append(_portrait_crop_filter(portrait_ratio, crop_center))
if short_side is not None:
# Scale so the shorter dimension equals short_side.
# if(lt(iw,ih),...) → portrait output: fix width; landscape: fix height.
# -2 keeps aspect ratio with even-pixel rounding (libx264 requirement).
filters.append(
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})'"
)
if filters:
cmd += ["-vf", ",".join(filters)]
cmd += ["-c:v", "libx264", "-c:a", "aac", output_path]
return cmd
```
**Step 5: Run portrait tests**
```bash
cd /media/p5/8-cut && python -m pytest tests/test_utils.py -k "portrait or resize or ffmpeg" -v 2>&1 | tail -20
```
Expected: all 5 ffmpeg tests pass.
**Step 6: Run full suite**
```bash
cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -30
```
Expected: all 23 tests pass.
**Step 7: Commit**
```bash
cd /media/p5/8-cut && git add main.py tests/test_utils.py && git commit -m "feat: portrait crop filter in build_ffmpeg_command"
```
---
### Task 2: `CropBarWidget`
**Files:**
- Modify: `main.py` — add `CropBarWidget` class before `PlaylistWidget`
**Step 1: Add `CropBarWidget` class**
Insert the following class in `main.py` right before `class PlaylistWidget` (currently around line 331):
```python
class CropBarWidget(QWidget):
"""Thin bar showing the portrait crop window position within the frame width.
Full bar width = source frame width (100%).
Highlighted region = selected crop window proportion.
Click to reposition crop center.
"""
crop_changed = pyqtSignal(float) # emits clamped crop center 0.01.0
def __init__(self):
super().__init__()
self.setFixedHeight(16)
self.setMouseTracking(True)
self._source_ratio: float = 16 / 9 # w/h of source video
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
self._crop_center: float = 0.5
def set_source_ratio(self, w: int, h: int) -> None:
self._source_ratio = w / h if h > 0 else 16 / 9
self.update()
def set_portrait_ratio(self, ratio: str | None) -> None:
self._portrait_ratio = _RATIOS[ratio] if ratio else None
self.update()
def set_crop_center(self, frac: float) -> None:
self._crop_center = max(0.0, min(1.0, frac))
self.update()
def _crop_window_frac(self) -> float:
"""Crop window width as a fraction of the bar (01)."""
if self._portrait_ratio is None:
return 1.0
num, den = self._portrait_ratio
portrait_ar = num / den # e.g. 9/16 = 0.5625
return portrait_ar / self._source_ratio # fraction of source width
def paintEvent(self, event):
p = QPainter(self)
try:
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(40, 40, 40))
if self._portrait_ratio is None:
return
win_frac = self._crop_window_frac()
win_px = int(w * win_frac)
max_x = w - win_px
x = int(max_x * self._crop_center)
# Crop window highlight
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
# Border
pen = QPen(QColor(100, 160, 240))
pen.setWidth(1)
p.setPen(pen)
p.drawRect(x, 1, win_px - 1, h - 2)
finally:
p.end()
def mousePressEvent(self, event):
self._update_from_x(event.position().x())
def mouseMoveEvent(self, event):
if event.buttons():
self._update_from_x(event.position().x())
def _update_from_x(self, x: float) -> None:
if self._portrait_ratio is None:
return
w = self.width()
win_frac = self._crop_window_frac()
win_px = w * win_frac
max_x = w - win_px
if max_x <= 0:
frac = 0.5
else:
# Center the window on the click point
frac = (x - win_px / 2) / max_x
frac = max(0.0, min(1.0, frac))
self.set_crop_center(frac)
self.crop_changed.emit(self._crop_center)
```
**Step 2: Verify headless import**
```bash
cd /media/p5/8-cut && python -c "from main import CropBarWidget"
```
Expected: no output.
**Step 3: Run all tests**
```bash
cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -20
```
Expected: all 23 tests pass.
**Step 4: Commit**
```bash
cd /media/p5/8-cut && git add main.py && git commit -m "feat: CropBarWidget for portrait crop positioning"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update imports, `MpvWidget`, `ExportWorker`, `MainWindow`
**Step 1: Add `QComboBox` to QtWidgets imports**
Current line 1014:
```python
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
)
```
Add `QComboBox`:
```python
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox,
)
```
**Step 2: Add `crop_clicked` signal and `get_video_size` to `MpvWidget`**
`MpvWidget` currently declares one signal: `file_loaded = pyqtSignal()` (line 271).
Add `crop_clicked = pyqtSignal(float)` on the next line:
```python
class MpvWidget(QFrame):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
crop_clicked = pyqtSignal(float) # x fraction 01 when user clicks video
```
Add `mousePressEvent` and `get_video_size` methods to `MpvWidget` right before `closeEvent`:
```python
def get_video_size(self) -> tuple[int, int]:
if self._player:
return (self._player.width or 0, self._player.height or 0)
return (0, 0)
def mousePressEvent(self, event):
w = self.width()
if w > 0:
self.crop_clicked.emit(event.position().x() / w)
```
**Step 3: Update `ExportWorker` to accept portrait params**
Current `ExportWorker.__init__` signature (line 154):
```python
def __init__(self, input_path: str, start: float, output_path: str,
short_side: int | None = None):
```
Replace the entire `ExportWorker` class with:
```python
class ExportWorker(QThread):
finished = pyqtSignal(str) # output path
error = pyqtSignal(str) # error message
def __init__(self, input_path: str, start: float, output_path: str,
short_side: int | None = None,
portrait_ratio: str | None = None,
crop_center: float = 0.5):
super().__init__()
self._input = input_path
self._start = start
self._output = output_path
self._short_side = short_side
self._portrait_ratio = portrait_ratio
self._crop_center = crop_center
def run(self):
cmd = build_ffmpeg_command(
self._input, self._start, self._output,
short_side=self._short_side,
portrait_ratio=self._portrait_ratio,
crop_center=self._crop_center,
)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
self.finished.emit(self._output)
else:
self.error.emit(result.stderr[-500:])
except FileNotFoundError:
self.error.emit("ffmpeg not found — is it installed and on PATH?")
except Exception as e:
self.error.emit(str(e))
```
**Step 4: Add portrait state + widgets in `MainWindow.__init__`**
In `MainWindow.__init__`, after the `self._settings` / `self._txt_resize` block (around line 468), add:
```python
self._crop_center: float = float(
self._settings.value("crop_center", "0.5")
)
self._cmb_portrait = QComboBox()
self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"])
saved_ratio = self._settings.value("portrait_ratio", "Off")
idx = self._cmb_portrait.findText(saved_ratio)
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
self._crop_bar = CropBarWidget()
self._crop_bar.set_crop_center(self._crop_center)
self._crop_bar.set_portrait_ratio(
None if saved_ratio == "Off" else saved_ratio
)
self._crop_bar.crop_changed.connect(self._on_crop_click)
self._mpv.crop_clicked.connect(self._on_crop_click)
```
**Step 5: Add portrait combo to export row and crop bar to layout**
Replace the current `export_row` block and the right-side layout block:
```python
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(QLabel("Short side:"))
export_row.addWidget(self._txt_resize)
export_row.addWidget(QLabel("Portrait:"))
export_row.addWidget(self._cmb_portrait)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline)
right_layout.addWidget(self._crop_bar)
right_layout.addLayout(controls)
right_layout.addLayout(export_row)
```
**Step 6: Add `_on_portrait_ratio_changed` and `_on_crop_click` methods**
Add these two methods after `_refresh_markers` (after line 554):
```python
def _on_portrait_ratio_changed(self, text: str) -> None:
ratio = None if text == "Off" else text
self._crop_bar.set_portrait_ratio(ratio)
self._crop_bar.setVisible(ratio is not None)
self._settings.setValue("portrait_ratio", text)
def _on_crop_click(self, frac: float) -> None:
ratio = self._cmb_portrait.currentText()
if ratio == "Off":
return
self._crop_center = max(0.0, min(1.0, frac))
self._settings.setValue("crop_center", str(self._crop_center))
self._crop_bar.set_crop_center(self._crop_center)
```
**Step 7: Update `_after_load` to set source ratio on crop bar**
Add `self._crop_bar.set_source_ratio(*self._mpv.get_video_size())` at the end of `_after_load`, just before `self._refresh_markers()`:
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
else:
self.statusBar().clearMessage()
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
self._refresh_markers()
```
**Step 8: Update `_on_export` to pass portrait params**
Replace the `self._export_worker = ExportWorker(...)` call in `_on_export` with:
```python
ratio_text = self._cmb_portrait.currentText()
portrait_ratio = None if ratio_text == "Off" else ratio_text
self._export_worker = ExportWorker(
self._file_path, self._cursor, output,
short_side=short_side,
portrait_ratio=portrait_ratio,
crop_center=self._crop_center,
)
```
**Step 9: Initialize crop bar visibility**
At the end of `__init__`, after `self.setStatusBar(QStatusBar())`, add:
```python
self._crop_bar.setVisible(saved_ratio != "Off")
```
**Step 10: Verify headless import**
```bash
cd /media/p5/8-cut && python -c "from main import MainWindow"
```
Expected: no output.
**Step 11: Run all tests**
```bash
cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -30
```
Expected: all 23 tests pass.
**Step 12: Commit**
```bash
cd /media/p5/8-cut && git add main.py && git commit -m "feat: wire portrait crop into MainWindow"
```
---
### Manual smoke test
```bash
python /media/p5/8-cut/main.py
```
- Drop a landscape video (e.g. 16:9)
- Select "9:16" from Portrait dropdown → crop bar appears below timeline
- Click video or drag crop bar → highlighted region moves
- Export → verify output is portrait with `ffprobe -v error -select_streams v:0 -show_entries stream=width,height output.mp4`
- Set Short side to 256 + Portrait 9:16 → output should be portrait 144×256
- Select "Off" → crop bar hides, export is normal landscape
- Relaunch → portrait ratio and crop center are restored