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

17 KiB
Raw Blame History

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:

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

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):

_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):

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

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

cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -30

Expected: all 23 tests pass.

Step 7: Commit

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):

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

cd /media/p5/8-cut && python -c "from main import CropBarWidget"

Expected: no output.

Step 3: Run all tests

cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -20

Expected: all 23 tests pass.

Step 4: Commit

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:

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
    QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
)

Add QComboBox:

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:

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:

    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):

    def __init__(self, input_path: str, start: float, output_path: str,
                 short_side: int | None = None):

Replace the entire ExportWorker class with:

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:

        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:

        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):

    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():

    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:

        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:

        self._crop_bar.setVisible(saved_ratio != "Off")

Step 10: Verify headless import

cd /media/p5/8-cut && python -c "from main import MainWindow"

Expected: no output.

Step 11: Run all tests

cd /media/p5/8-cut && python -m pytest tests/ -v 2>&1 | tail -30

Expected: all 23 tests pass.

Step 12: Commit

cd /media/p5/8-cut && git add main.py && git commit -m "feat: wire portrait crop into MainWindow"

Manual smoke test

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