Files
8-cut/docs/plans/2026-04-06-resize-output-implementation.md
T

9.1 KiB

Resize Output Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a user-configurable short-side resize to every exported clip, persisted between relaunches.

Architecture: build_ffmpeg_command gains an optional short_side: int | None parameter that appends a -vf scale= filter. MainWindow gets a QLineEdit in the export row wired to QSettings for persistence; _on_export parses it and passes the value through to ExportWorker.

Tech Stack: Python built-ins, PyQt6 (QSettings, QLineEdit), ffmpeg -vf scale filter. No new dependencies.


Task 1: Update build_ffmpeg_command (TDD)

Files:

  • Modify: main.py — update build_ffmpeg_command
  • Modify: tests/test_utils.py — update existing test, add new test

Step 1: Write failing tests

In tests/test_utils.py, the existing test_ffmpeg_command (line 29) tests the no-resize case. Rename it and add a resize test below it:

def test_ffmpeg_command_no_resize():
    cmd = build_ffmpeg_command("/in/video.mp4", 12.5, "/out/clip_001.mp4")
    assert cmd[0] == "ffmpeg"
    assert "-y" in cmd
    assert "-ss" in cmd
    assert str(12.5) in cmd
    assert "-t" in cmd
    assert "8" in cmd
    assert cmd[-1] == "/out/clip_001.mp4"
    assert "-vf" not in cmd

def test_ffmpeg_command_with_resize():
    cmd = build_ffmpeg_command("/in/video.mp4", 0.0, "/out/clip_001.mp4", short_side=256)
    assert "-vf" in cmd
    vf_value = cmd[cmd.index("-vf") + 1]
    assert "256" in vf_value
    assert "scale" in vf_value
    assert cmd[-1] == "/out/clip_001.mp4"

Step 2: Run to verify they fail

cd /media/p5/8-cut && python -m pytest tests/test_utils.py -k "ffmpeg" -v 2>&1 | tail -20

Expected: test_ffmpeg_command (old name) passes but the new tests don't exist yet — after renaming, test_ffmpeg_command_no_resize will fail because -vf is not in the command (the assert "-vf" not in cmd will pass, so actually both new tests should pass on the no-resize one, but test_ffmpeg_command_with_resize will fail with TypeError because short_side param doesn't exist). That's the expected failure.

Step 3: Update build_ffmpeg_command in main.py

Replace the function (lines 33-44) with:

def build_ffmpeg_command(
    input_path: str, start: float, output_path: str,
    short_side: int | None = None,
) -> 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",
    ]
    if short_side is not None:
        # Scale so the shorter dimension equals short_side.
        # if(lt(iw,ih),...) → portrait: fix width; landscape: fix height.
        # -2 keeps aspect ratio with even-pixel rounding (libx264 requirement).
        scale = f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})'"
        cmd += ["-vf", scale]
    cmd += ["-c:v", "libx264", "-c:a", "aac", output_path]
    return cmd

Step 4: Run ffmpeg tests

cd /media/p5/8-cut && python -m pytest tests/test_utils.py -k "ffmpeg" -v 2>&1 | tail -20

Expected: both ffmpeg tests pass.

Step 5: Run full suite

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

Expected: all tests pass.

Step 6: Commit

cd /media/p5/8-cut && git add main.py tests/test_utils.py && git commit -m "feat: add short_side resize to build_ffmpeg_command"

Task 2: Wire QSettings + UI into MainWindow

Files:

  • Modify: main.py — add QSettings import, add _txt_resize widget, wire persistence, update _on_export

Step 1: Add QSettings to imports

In main.py, the QtCore import line is:

from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal

Add QSettings:

from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings

Step 2: Add _txt_resize widget in MainWindow.__init__

After self._btn_folder = QPushButton("Browse") (around line 448), add:

        self._settings = QSettings("8cut", "8cut")
        self._txt_resize = QLineEdit()
        self._txt_resize.setPlaceholderText("px (opt.)")
        self._txt_resize.setMaximumWidth(70)
        self._txt_resize.setText(self._settings.value("resize_short_side", ""))
        self._txt_resize.textChanged.connect(
            lambda v: self._settings.setValue("resize_short_side", v)
        )

Step 3: Add the resize field to the export row

The export row currently (lines 468-475) is:

        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(self._lbl_next)
        export_row.addWidget(self._btn_export)

Replace with:

        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(self._lbl_next)
        export_row.addWidget(self._btn_export)

Step 4: Update _on_export to parse the resize field and pass short_side

The current _on_export (line 571) creates ExportWorker like this:

        self._export_worker = ExportWorker(self._file_path, self._cursor, output)

ExportWorker.__init__ calls build_ffmpeg_command(input_path, start, output_path) internally — wait, actually check how ExportWorker works. Read the file to confirm.

ExportWorker.run calls build_ffmpeg_command(self._input, self._start, self._output). So we need to pass short_side through ExportWorker too.

Update ExportWorker.__init__ to accept short_side: int | None = None and store it, then pass it to build_ffmpeg_command in run:

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):
        super().__init__()
        self._input = input_path
        self._start = start
        self._output = output_path
        self._short_side = short_side

    def run(self):
        cmd = build_ffmpeg_command(self._input, self._start, self._output,
                                   self._short_side)
        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))

Then in _on_export, parse the resize field and pass it:

    def _on_export(self):
        if not self._file_path:
            return
        if self._export_worker and self._export_worker.isRunning():
            self.statusBar().showMessage("Export already running…")
            return

        output = build_export_path(
            self._txt_folder.text(),
            self._txt_name.text() or "clip",
            self._export_counter,
        )

        raw = self._txt_resize.text().strip()
        try:
            short_side = int(raw) if raw else None
            if short_side is not None and short_side <= 0:
                short_side = None
        except ValueError:
            short_side = None

        self._btn_export.setEnabled(False)
        self.statusBar().showMessage(f"Exporting {os.path.basename(output)}…")

        self._export_worker = ExportWorker(self._file_path, self._cursor, output,
                                           short_side)
        self._export_worker.finished.connect(self._on_export_done)
        self._export_worker.error.connect(self._on_export_error)
        self._export_worker.start()

Step 5: Verify headless import

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

Expected: no output.

Step 6: Run all tests

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

Expected: all tests pass.

Step 7: Commit

cd /media/p5/8-cut && git add main.py && git commit -m "feat: resize short-side field with QSettings persistence"

Manual smoke test

python /media/p5/8-cut/main.py
  • Leave "Short side" blank → export at native resolution
  • Type 256 in "Short side" → export; verify output with ffprobe output.mp4 that the shorter dimension is 256
  • Relaunch the app → 256 is still in the field
  • Type garbage (abc) → export still works, no resize applied