diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ead320 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + env: + QT_QPA_PLATFORM: offscreen + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install Linux Qt runtime libs + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest -m "not integration" -q diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dc931ee..8b9e0b9 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,7 +6,11 @@ on: - main paths: - docs/** + - VERSION - .github/workflows/pages.yml + release: + types: [published] + workflow_dispatch: permissions: contents: write @@ -18,6 +22,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Sync download links to current VERSION + run: | + VERSION="$(cat VERSION)" + sed -i -E "s/Datamosh-[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?-(linux|windows|macos)/Datamosh-${VERSION}-\2/g" docs/index.html + sed -i -E "s#// download v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?#// download v${VERSION}#g" docs/index.html + sed -i -E "s#(
v)[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?#\1${VERSION}#g" docs/index.html + - name: Deploy to gh-pages branch uses: peaceiris/actions-gh-pages@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 269450b..9278984 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,22 @@ jobs: Set-Content -Path VERSION -Value $version -NoNewline "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Download FFmpeg for bundling + shell: pwsh + run: | + # Ship ffmpeg/ffprobe inside the Windows build so the app works on + # machines without ffmpeg on PATH (gui/ffmpeg_env.ensure_ffmpeg_on_path + # prepends the bundled "ffmpeg" dir at startup). GPL build is fine for a + # GPL-3.0 project. Pin to a specific release for reproducible builds. + $url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip" + Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip + Expand-Archive -Path ffmpeg.zip -DestinationPath ffmpeg-zip -Force + New-Item -ItemType Directory -Force -Path ffmpeg-bin | Out-Null + $ff = Get-ChildItem -Path ffmpeg-zip -Recurse -Filter ffmpeg.exe | Select-Object -First 1 + $fp = Get-ChildItem -Path ffmpeg-zip -Recurse -Filter ffprobe.exe | Select-Object -First 1 + Copy-Item $ff.FullName ffmpeg-bin/ffmpeg.exe + Copy-Item $fp.FullName ffmpeg-bin/ffprobe.exe + - name: Build app shell: pwsh run: | @@ -86,6 +102,8 @@ jobs: --add-data "README.md;." ` --add-data "LICENSE;." ` --add-data "VERSION;." ` + --add-binary "ffmpeg-bin/ffmpeg.exe;ffmpeg" ` + --add-binary "ffmpeg-bin/ffprobe.exe;ffmpeg" ` main.py - name: Install Inno Setup diff --git a/.github/workflows/update_beta5_release.yml b/.github/workflows/update_beta5_release.yml deleted file mode 100644 index b530dee..0000000 --- a/.github/workflows/update_beta5_release.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Update Beta 5 Release Notes - -on: - push: - branches: - - main - paths: - - RELEASE_BODY.md - - .github/workflows/update_beta5_release.yml - -permissions: - contents: write - -jobs: - update-release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Promote v1.1.0-beta.5 as current release - uses: actions/github-script@v7 - with: - script: | - const fs = require("fs"); - const owner = context.repo.owner; - const repo = context.repo.repo; - const tag = "v1.1.0-beta.5"; - const body = fs.readFileSync("RELEASE_BODY.md", "utf8"); - - const release = await github.rest.repos.getReleaseByTag({ - owner, - repo, - tag, - }); - - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: release.data.id, - name: "Datamosh v1.1.0 (Linux/macOS)", - body, - draft: false, - prerelease: false, - make_latest: "true", - }); diff --git a/CHANGELOG.md b/CHANGELOG.md index b283257..6c01d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ All notable changes to this project are documented here. -## [Unreleased] - 2026-02-17 +## [Unreleased] + +## [1.1.5] - 2026-02-28 + +### Fixed +- Temp directories from normalization and I-frame injection are now cleaned up when a clip is removed, preventing `/tmp` accumulation over long sessions. +- Video info probing (fps, frame count, dimensions) moved off the Qt main thread — no more UI freeze after importing clips. +- Timeline scroll wheel now pans; `Ctrl+wheel` zooms (was inverted vs. shortcut docs). +- Clip list view now highlights the correct row when a timeline clip is clicked. +- Settings panel controls are now disabled when no clip is selected (prevented silent no-op slider interactions). +- "Cut At Playhead" context menu item disabled on empty timeline. +- `UpdateWorker` exceptions no longer permanently block future update checks. +- OpenCV video capture handle always released via `finally` (prevents file lock on Windows). +- `RenderDialog` stops its render worker when the dialog is closed mid-render. +- Drag-reorder MIME data parse is now validated; malformed drops return `False` cleanly. +- Undo/redo stacks use `deque(maxlen=200)` for O(1) eviction. +- Timeline hint text contrast raised to meet WCAG AA minimum. ## [1.1.4] - 2026-02-18 diff --git a/CLAUDE.md b/CLAUDE.md index d1f6a39..4cde305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Tests are in `tests/` with markers `integration` (requires real video files), `g ## Architecture -The application has two layers: a pure binary AVI manipulation engine (`mosh.py`, treat as stable/untouched) and a PySide6 GUI (`gui/`, `main.py`). +The application has two layers: a pure binary AVI manipulation engine (`mosh.py`) and a PySide6 GUI (`gui/`, `main.py`). `mosh.py` is the most safety-critical code — change it with care and keep `tests/test_mosh*.py` green; it must still round-trip its own normalized output byte-for-byte. ### Core engine (`mosh.py`) Operates directly on the RIFF/AVI binary format — no video decoding. Pipeline: @@ -97,4 +97,4 @@ Multiple clips concatenated at chunk level, each tagged with `clip_id` for indep - `ffmpeg` and `ffprobe` must be on PATH. - `opencv-python-headless` is used for frame extraction; falls back to ffmpeg pipe if unavailable. - Output format is always AVI (Xvid). MP4/WebM export not implemented. -- `mosh.py` must not be modified — the GUI wraps it via `MoshWorker`. +- `mosh.py` is the core engine, wrapped by `MoshWorker`. It may be modified, but carefully: keep its round-trip behavior intact and `tests/test_mosh*.py` green. idx1 is treated as advisory (used only for keyframe flags); output is capped at the 4 GB AVI limit with a clear error. diff --git a/README.md b/README.md index 0b3f1c5..057e2c9 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,10 @@ pyinstaller --noconfirm --clean --windowed --name Datamosh \ ## Keyboard Shortcuts +- `Ctrl+N`: new project +- `Ctrl+Shift+P`: open project (`.dmosh`) +- `Ctrl+S`: save project +- `Ctrl+Shift+S`: save project as - `Ctrl+O`: open clips - `Ctrl+Shift+O`: add clips - `Ctrl+R`: render @@ -192,6 +196,15 @@ Headless-safe full suite: QT_QPA_PLATFORM=offscreen pytest -q ``` +## Projects + +Save your whole session — clips, per-clip mosh settings, timeline arrangement, cuts, +and injected I-frames — to a `.dmosh` project file (`File > Save Project`, `Ctrl+S`). +Reopening (`File > Open Project`, `Ctrl+Shift+P`) recreates the clips from their source +paths and re-ingests them, so projects stay small and portable. Source media must still +be present at its saved path; missing sources are reported and left empty. The window +title shows the project name and a `*` when there are unsaved changes. + ## Notes - Current output target is AVI/Xvid. diff --git a/ROADMAP.md b/ROADMAP.md index 120ea07..de8a14e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,8 @@ Product direction for Datamosh GUI (timeline-first, experimental mosh workflow). - Define expected pass/fail behavior and fallback messaging. ### 2. Import Strategy UI (Audit + Upgrade) +> **Status: shipped in v1.1.1** — import options dialog with normalize-all vs. direct-AVI modes, preset/custom controls (width/height/GOP/qscale/audio), and a persistent import profile. Remaining work below is refinement only. + - Audit current import path (auto-normalize to Xvid) and verify edge-case behavior. - Add an import options dialog before ingest: - Keep original if compatible @@ -47,6 +49,8 @@ Definition of done: ## Phase 3: UI Polish and Workflow Speed ### 5. Toolbar Refresh +> **Status: shipped** — the toolbar is now icon-only (`ToolButtonIconOnly`) with tooltips and keyboard accelerators. Remaining work is a custom scalable icon set. + - Replace text actions with icon-first toolbar + tooltips. - Keep keyboard shortcuts primary; toolbar as visual accelerator. - Add scalable icon set for light/dark readability. @@ -57,8 +61,12 @@ Definition of done: - Background decode/analysis prioritization for active viewport. ## Near-Term Priority Order -1. Example media suite + ingest reliability tests -2. Import strategy dialog and profile system +1. **Project persistence** — save/load `.dmosh` project files (timeline arrangement + per-clip settings + cuts + injected I-frames). Foundational: today all work is lost on close. +2. Example media suite + ingest reliability tests 3. Layered timeline foundation 4. Opacity/blend modes -5. Icon toolbar and final UI pass +5. Custom scalable icon set for the toolbar + +### Already shipped +- Import strategy dialog and persistent import profiles (v1.1.1) +- Icon-first toolbar with tooltips and accelerators diff --git a/docs/index.html b/docs/index.html index 25239d7..c9393d1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -659,25 +659,25 @@

Get Datamosh GUI

Linux
x86_64
Windows
x86_64
macOS
universal
diff --git a/gui/app.py b/gui/app.py index 32f686a..11fe254 100644 --- a/gui/app.py +++ b/gui/app.py @@ -6,6 +6,7 @@ from PySide6.QtCore import QLibraryInfo from PySide6.QtWidgets import QApplication +from gui.ffmpeg_env import suppress_subprocess_console from gui.theme import STYLESHEET, make_dark_palette from gui.version import get_version @@ -29,6 +30,7 @@ def create_app(argv: list[str] | None = None) -> QApplication: if argv is None: argv = sys.argv sanitize_qt_plugin_env() + suppress_subprocess_console() # no ffmpeg/ffprobe console flashes on Windows app = QApplication(argv) app.setApplicationName("Datamosh") app.setApplicationVersion(get_version()) diff --git a/gui/dialogs/render_dialog.py b/gui/dialogs/render_dialog.py index 2cdf3bc..a4ed1ab 100644 --- a/gui/dialogs/render_dialog.py +++ b/gui/dialogs/render_dialog.py @@ -6,14 +6,23 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QComboBox, QDialog, QFileDialog, + QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout, ) +# label, extension, file-dialog filter +EXPORT_FORMATS = [ + ("AVI — Xvid (native, fastest)", "avi", "AVI Files (*.avi)"), + ("MP4 — H.264 (shareable)", "mp4", "MP4 Files (*.mp4)"), + ("MOV — H.264", "mov", "MOV Files (*.mov)"), +] + from gui.models.project import Project from gui.workers.mosh_worker import MoshWorker @@ -36,6 +45,14 @@ def _build_ui(self) -> None: self._info.setWordWrap(True) layout.addWidget(self._info) + fmt_row = QHBoxLayout() + fmt_row.addWidget(QLabel("Format:")) + self._format = QComboBox() + for label, ext, _filter in EXPORT_FORMATS: + self._format.addItem(label, ext) + fmt_row.addWidget(self._format, 1) + layout.addLayout(fmt_row) + self._progress = QProgressBar() self._progress.setRange(0, 0) # indeterminate self._progress.hide() @@ -54,17 +71,23 @@ def _start(self) -> None: self._info.setText("Some timeline clips are still normalizing. Please wait.") return + ext = self._format.currentData() + flt = next((f for _l, e, f in EXPORT_FORMATS if e == ext), "All Files (*)") path, _ = QFileDialog.getSaveFileName( - self, "Save Moshed AVI", "", "AVI Files (*.avi);;All Files (*)" + self, "Save Moshed Video", "", f"{flt};;All Files (*)" ) if not path: return + out = Path(path) + if out.suffix.lower() != f".{ext}": + out = out.with_suffix(f".{ext}") self._btn.setEnabled(False) self._progress.show() - self._info.setText("Rendering...") + self._info.setText("Rendering (transcoding to MP4/MOV may take a little longer)..." + if ext != "avi" else "Rendering...") - self._worker = MoshWorker(self._project.timeline_render_clips(), Path(path), self) + self._worker = MoshWorker(self._project.timeline_render_clips(), out, self) self._worker.finished_ok.connect(self._on_done) self._worker.error.connect(self._on_error) self._worker.finished.connect(self._worker.deleteLater) diff --git a/gui/ffmpeg_env.py b/gui/ffmpeg_env.py new file mode 100644 index 0000000..0963314 --- /dev/null +++ b/gui/ffmpeg_env.py @@ -0,0 +1,71 @@ +"""Locate ffmpeg/ffprobe and keep child processes from flashing console windows. + +The engine and workers shell out to ``ffmpeg``/``ffprobe`` by bare name, relying +on PATH. On Windows that means (a) a packaged build must ship the binaries and put +them on PATH, and (b) every child process must be created with ``CREATE_NO_WINDOW`` +or it flashes a console window in the ``--windowed`` build. Both concerns live here +so the rest of the code (including the untouched ``mosh.py``) needs no changes. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +#: Windows flag that prevents a child process from allocating a console window. +_CREATE_NO_WINDOW = 0x08000000 + + +def _bundled_ffmpeg_dir() -> Optional[Path]: + """Return the bundled ``ffmpeg`` directory in a frozen build, if present. + + The release build is expected to drop ffmpeg.exe/ffprobe.exe into an + ``ffmpeg`` subfolder of the PyInstaller bundle (see release.yml --add-binary). + """ + if not getattr(sys, "frozen", False): + return None + base = getattr(sys, "_MEIPASS", None) or os.path.dirname(sys.executable) + candidate = Path(base) / "ffmpeg" + return candidate if candidate.is_dir() else None + + +def ensure_ffmpeg_on_path() -> None: + """Prepend a bundled ffmpeg directory to PATH so bare ``ffmpeg``/``ffprobe`` + calls resolve to the shipped binaries. No-op for source/dev runs.""" + bundled = _bundled_ffmpeg_dir() + if bundled is not None: + os.environ["PATH"] = str(bundled) + os.pathsep + os.environ.get("PATH", "") + + +def missing_ffmpeg_tools() -> list[str]: + """Return the subset of ('ffmpeg', 'ffprobe') not resolvable on PATH.""" + return [tool for tool in ("ffmpeg", "ffprobe") if shutil.which(tool) is None] + + +def suppress_subprocess_console() -> None: + """On Windows, default every child process to ``CREATE_NO_WINDOW``. + + Installs a ``subprocess.Popen`` subclass (which ``run``/``call``/``check_*`` + all funnel through) that adds the flag unless the caller set ``creationflags``. + This silences the console-window flash from ffmpeg/ffprobe — including the + calls inside ``mosh.py`` — without editing any call site. Idempotent. + """ + if sys.platform != "win32": + return + base_popen = subprocess.Popen + if getattr(base_popen, "_datamosh_no_window", False): + return + + class _NoWindowPopen(base_popen): # type: ignore[misc, valid-type] + _datamosh_no_window = True + + def __init__(self, *args, **kwargs): + if not kwargs.get("creationflags"): + kwargs["creationflags"] = _CREATE_NO_WINDOW + super().__init__(*args, **kwargs) + + subprocess.Popen = _NoWindowPopen # type: ignore[assignment] diff --git a/gui/main_window.py b/gui/main_window.py index f3b61da..4d14356 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -1,8 +1,12 @@ """Main window with splitter-based layout.""" +from pathlib import Path +from typing import Optional + from PySide6.QtCore import Qt, Signal, QUrl from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QKeySequence from PySide6.QtWidgets import ( + QFileDialog, QMainWindow, QMessageBox, QSplitter, @@ -11,6 +15,8 @@ QWidget, ) +from gui.models import project_io + from gui.widgets.clip_panel import ClipPanel from gui.widgets.preview_widget import PreviewWidget from gui.widgets.settings_panel import SettingsPanel @@ -41,10 +47,14 @@ def __init__(self) -> None: self.project = Project() self._update_worker: UpdateWorker | None = None + self._project_path: Optional[Path] = None + self._dirty = False + self._loading = False self._build_ui() self._connect_signals() self.setAcceptDrops(True) + self._update_title() # -- UI construction --------------------------------------------------- @@ -108,6 +118,28 @@ def _build_menus(self) -> None: menu = self.menuBar() file_menu = menu.addMenu("&File") + + self._new_action = QAction("New Project", self) + self._new_action.setShortcut(QKeySequence.StandardKey.New) + self._new_action.triggered.connect(self._new_project) + file_menu.addAction(self._new_action) + + self._open_project_action = QAction("Open Project...", self) + self._open_project_action.setShortcut(QKeySequence("Ctrl+Shift+P")) + self._open_project_action.triggered.connect(self._open_project) + file_menu.addAction(self._open_project_action) + + self._save_action = QAction("Save Project", self) + self._save_action.setShortcut(QKeySequence.StandardKey.Save) + self._save_action.triggered.connect(self._save_project) + file_menu.addAction(self._save_action) + + self._save_as_action = QAction("Save Project As...", self) + self._save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S")) + self._save_as_action.triggered.connect(self._save_project_as) + file_menu.addAction(self._save_as_action) + + file_menu.addSeparator() file_menu.addAction(self.toolbar.open_action) file_menu.addAction(self.toolbar.add_action) file_menu.addSeparator() @@ -181,6 +213,14 @@ def _connect_signals(self) -> None: self._set_history_state(self.project.can_undo(), self.project.can_redo()) self._set_timeline_menu_state() + # Unsaved-changes tracking. rowsInserted/rowsRemoved fire on add/remove + # (not on normalize-completion, which only updates row data), so a loaded + # project stays clean until the user actually edits it. + self.project.clip_updated.connect(self._mark_dirty) + self.project.timeline_changed.connect(self._mark_dirty) + self.project.clip_model.rowsInserted.connect(self._mark_dirty) + self.project.clip_model.rowsRemoved.connect(self._mark_dirty) + # -- Drag-and-drop ----------------------------------------------------- def dragEnterEvent(self, event) -> None: @@ -193,10 +233,14 @@ def dropEvent(self, event) -> None: self.files_dropped.emit(paths) def closeEvent(self, event: QCloseEvent) -> None: + if not self._maybe_discard_changes(): + event.ignore() + return try: self.preview_widget.shutdown() self.timeline_widget.shutdown() self.clip_panel.shutdown() + self.project.cleanup_all() if self._update_worker and self._update_worker.isRunning(): self._update_worker.quit() if not self._update_worker.wait(1000): @@ -205,6 +249,128 @@ def closeEvent(self, event: QCloseEvent) -> None: finally: super().closeEvent(event) + # -- Project persistence ---------------------------------------------- + + def _mark_dirty(self, *_args) -> None: + if self._loading or self._dirty: + return + self._dirty = True + self._update_title() + + def _set_clean(self) -> None: + self._dirty = False + self._update_title() + + def _update_title(self) -> None: + name = self._project_path.name if self._project_path else "Untitled" + star = "*" if self._dirty else "" + self.setWindowTitle(f"Datamosh {get_version()} — {name}{star}") + + def _maybe_discard_changes(self) -> bool: + """Prompt to save/discard if dirty. Return True to proceed, False to cancel.""" + if not self._dirty: + return True + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText("This project has unsaved changes.") + box.setInformativeText("Save them before continuing?") + save_btn = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole) + box.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole) + cancel_btn = box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + clicked = box.clickedButton() + if clicked == cancel_btn: + return False + if clicked == save_btn: + return self._save_project() + return True # discard + + def _new_project(self) -> None: + if not self._maybe_discard_changes(): + return + self._loading = True + try: + self.clip_panel.cancel_ingest() + self.preview_widget.reset() + self.project.clear() + finally: + self._loading = False + self._project_path = None + self._set_clean() + self.set_status("New project", 1500) + + def _open_project(self) -> None: + if not self._maybe_discard_changes(): + return + start_dir = str(self._project_path.parent) if self._project_path else "" + path_str, _ = QFileDialog.getOpenFileName( + self, "Open Project", start_dir, + f"Datamosh Project (*{project_io.PROJECT_SUFFIX});;All Files (*)", + ) + if path_str: + self._load_project_file(Path(path_str)) + + def _load_project_file(self, path: Path) -> None: + try: + data = project_io.read_project(path) + except project_io.ProjectLoadError as exc: + QMessageBox.critical(self, "Open Project", str(exc)) + return + + self._loading = True + try: + self.clip_panel.cancel_ingest() + self.preview_widget.reset() + settings = data.get("import_settings") or self.clip_panel.current_import_settings() + clips = self.project.install_loaded_state(data) + for row in range(len(clips)): + self.clip_panel.reingest_loaded_clip(row, settings) + finally: + self._loading = False + + self._project_path = path + self._set_clean() + missing = sum(1 for c in self.project.clips if not c.source_path.is_file()) + if missing: + QMessageBox.warning( + self, "Open Project", + f"{missing} source file(s) could not be found. " + "Those clips will stay empty until their sources are available.", + ) + self.set_status(f"Opened {path.name}", 2500) + + def _save_project(self) -> bool: + if self._project_path is None: + return self._save_project_as() + return self._do_save_to(self._project_path) + + def _save_project_as(self) -> bool: + start = str(self._project_path) if self._project_path else f"untitled{project_io.PROJECT_SUFFIX}" + path_str, _ = QFileDialog.getSaveFileName( + self, "Save Project As", start, + f"Datamosh Project (*{project_io.PROJECT_SUFFIX});;All Files (*)", + ) + if not path_str: + return False + path = Path(path_str) + if path.suffix.lower() != project_io.PROJECT_SUFFIX: + path = path.with_suffix(project_io.PROJECT_SUFFIX) + return self._do_save_to(path) + + def _do_save_to(self, path: Path) -> bool: + try: + project_io.write_project( + path, self.project, self.clip_panel.current_import_settings(), get_version() + ) + except OSError as exc: + QMessageBox.critical(self, "Save Project", f"Could not save project:\n{exc}") + return False + self._project_path = path + self._set_clean() + self.set_status(f"Saved {path.name}", 2500) + return True + # -- Actions ----------------------------------------------------------- def _on_render(self) -> None: diff --git a/gui/models/clip_model.py b/gui/models/clip_model.py index 601bf0a..1dbd5e9 100644 --- a/gui/models/clip_model.py +++ b/gui/models/clip_model.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Optional -from PySide6.QtCore import QAbstractListModel, QMimeData, QModelIndex, Qt +from PySide6.QtCore import QAbstractListModel, QMimeData, QModelIndex, Qt, Signal from PySide6.QtGui import QPixmap @@ -33,6 +33,9 @@ class ClipProfile: trim_start_frame: int = 0 trim_end_frame: int = 0 # exclusive; 0 means "use full clip" drop_first_keyframe_override: Optional[bool] = None + # "clip" = normal video import; "iframe" = injected single-frame clip. Drives + # how the clip is re-ingested when a saved .dmosh project is reopened. + source_kind: str = "clip" # Temp directory created during normalization or I-frame injection. temp_dir: Optional[Path] = field(default=None, repr=False, compare=False) @@ -59,6 +62,11 @@ class ClipListModel(QAbstractListModel): MIME_TYPE = "application/x-datamosh-clip-index" + # Emitted on a drag-drop reorder request (source_row, target_row). The owning + # Project performs the actual move so the reorder is undoable and keeps the + # selection / preview in sync — see Project._on_clips_reordered. + reorder_requested = Signal(int, int) + def __init__(self, parent=None) -> None: super().__init__(parent) self._clips: list[ClipProfile] = [] @@ -162,5 +170,7 @@ def dropMimeData(self, data: QMimeData, action, row, column, parent) -> bool: target = row if row >= 0 else self.rowCount() if source_row < target: target -= 1 - self.move_clip(source_row, target) + # Hand the move to the owning Project (records undo, fixes selection, + # emits clips_changed). It calls back into move_clip to mutate the list. + self.reorder_requested.emit(source_row, target) return True diff --git a/gui/models/project.py b/gui/models/project.py index 96e0548..00da6f7 100644 --- a/gui/models/project.py +++ b/gui/models/project.py @@ -14,12 +14,22 @@ @dataclass class TimelineItem: - """One timeline segment referencing a source clip.""" + """One timeline segment referencing a source clip. + + Glitch settings are per-segment: each ``*_override`` is None to inherit the + source clip's value, or a concrete value that wins for this segment only. This + lets the same clip appear twice on the timeline with different mosh settings. + """ clip: ClipProfile in_frame: int = 0 out_frame: int = 0 # exclusive; 0 means "full clip" drop_first_keyframe_override: Optional[bool] = None + keep_first_override: Optional[int] = None + duplicate_count_override: Optional[int] = None + duplicate_gap_override: Optional[int] = None + keep_keys_spec_override: Optional[str] = None + drop_keys_spec_override: Optional[str] = None @dataclass @@ -28,7 +38,7 @@ class ProjectSnapshot: clips: list[ClipProfile] clip_states: list[tuple[int, int, int, bool, str, str]] - timeline: list[tuple[int, int, int, Optional[bool]]] + timeline: list[dict] selected_row: int selected_timeline_index: int @@ -54,6 +64,11 @@ def __init__(self, parent=None) -> None: self._undo_stack: deque[ProjectSnapshot] = deque(maxlen=self._history_limit) self._redo_stack: deque[ProjectSnapshot] = deque(maxlen=self._history_limit) self._restoring_history = False + # Clips removed from the model but possibly still referenced by undo/redo + # history. Their temp dirs are reaped only once truly unreachable, so an + # undo can never restore a clip whose normalized media file was deleted. + self._pending_temp_cleanup: list[ClipProfile] = [] + self.clip_model.reorder_requested.connect(self._on_clips_reordered) # -- Properties -------------------------------------------------------- @@ -124,40 +139,82 @@ def remove_clip(self, row: int, *, record_undo: bool = True) -> None: return if record_undo: self._record_undo_state() - clip.cleanup() + # Defer temp-dir cleanup: the just-recorded undo snapshot still holds this + # clip (with its normalized_path), so deleting its temp dir now would make a + # later undo restore a clip whose media file is gone. Reap once unreachable. + if clip.temp_dir is not None and not any(c is clip for c in self._pending_temp_cleanup): + self._pending_temp_cleanup.append(clip) self.clip_model.remove_clip(row) self.clips_changed.emit() - if clip is not None: - removed = 0 - kept: list[TimelineItem] = [] - for item in self._timeline: - if item.clip is clip: - removed += 1 - else: - kept.append(item) - if removed: - old_sel = self._selected_timeline_index - self._timeline = kept - if not self._timeline: - self._selected_timeline_index = -1 + removed = 0 + kept: list[TimelineItem] = [] + for item in self._timeline: + if item.clip is clip: + removed += 1 + else: + kept.append(item) + if removed: + old_sel = self._selected_timeline_index + selected_item = ( + self._timeline[old_sel] if 0 <= old_sel < len(self._timeline) else None + ) + self._timeline = kept + if not self._timeline: + self._selected_timeline_index = -1 + else: + # Follow the still-present selected segment to its new index (it may + # have shifted down if an earlier segment was removed); otherwise the + # selected segment was one of the removed ones, so clamp into range. + new_idx = next( + (i for i, it in enumerate(self._timeline) if it is selected_item), + -1, + ) + if new_idx >= 0: + self._selected_timeline_index = new_idx elif old_sel >= len(self._timeline): self._selected_timeline_index = len(self._timeline) - 1 - self.timeline_changed.emit() - self.timeline_item_selected.emit(self._selected_timeline_index) - - if row == self._selected_row: - new = min(row, self.clip_model.rowCount() - 1) - self.select_clip(new) - elif row < self._selected_row: - self._selected_row -= 1 - - def select_clip(self, row: int) -> None: - if row == self._selected_row: + self.timeline_changed.emit() + self.timeline_item_selected.emit(self._selected_timeline_index) + + # Keep selection bound to the correct clip. When the removed row is at or + # before the selection, the clip now occupying that slot (or the shifted + # selected clip) differs from what the settings panel is bound to, so force + # a re-emit even when the row index is numerically unchanged. + old = self._selected_row + if old >= 0 and row <= old: + count = self.clip_model.rowCount() + if count == 0: + new = -1 + elif row < old: + new = old - 1 + else: # row == old + new = min(row, count - 1) + self.select_clip(new, force=True) + + self._reap_temp_dirs() + + def select_clip(self, row: int, *, force: bool = False) -> None: + if row == self._selected_row and not force: return self._selected_row = row self.clip_selected.emit(row) + def _on_clips_reordered(self, source_row: int, target_row: int) -> None: + """Perform a drag-drop reorder requested by the clip model: record undo, + move the clip, and rebind the selection to its new row. The model itself + only emits the request so the reorder stays undoable and in sync.""" + n = self.clip_model.rowCount() + if source_row == target_row or not (0 <= source_row < n and 0 <= target_row < n): + return + self._record_undo_state() + selected_clip = self.clip_model.clip_at(self._selected_row) + self.clip_model.move_clip(source_row, target_row) + new_row = self.row_for_clip(selected_clip) if selected_clip is not None else -1 + # Force a re-emit so the settings panel rebinds to the moved clip's new row. + self.select_clip(new_row, force=True) + self.clips_changed.emit() + def notify_clip_updated(self, row: int, *, record_undo: bool = True) -> None: if not (0 <= row < self.clip_model.rowCount()): return @@ -321,6 +378,43 @@ def set_timeline_item_drop_first( self.timeline_changed.emit() return True + def update_timeline_item_settings( + self, + index: int, + *, + keep_first: int, + duplicate_count: int, + duplicate_gap: int, + keep_keys_spec: str, + drop_keys_spec: str, + record_undo: bool = True, + ) -> bool: + """Set a timeline segment's per-segment glitch overrides. No-op if unchanged.""" + if not (0 <= index < len(self._timeline)): + return False + item = self._timeline[index] + new = (keep_first, duplicate_count, duplicate_gap, keep_keys_spec, drop_keys_spec) + cur = ( + item.keep_first_override, + item.duplicate_count_override, + item.duplicate_gap_override, + item.keep_keys_spec_override, + item.drop_keys_spec_override, + ) + if new == cur: + return False + if record_undo: + self._record_undo_state() + ( + item.keep_first_override, + item.duplicate_count_override, + item.duplicate_gap_override, + item.keep_keys_spec_override, + item.drop_keys_spec_override, + ) = new + self.timeline_changed.emit() + return True + def timeline_item_effective_drop_first(self, index: int) -> Optional[bool]: if not (0 <= index < len(self._timeline)): return None @@ -337,6 +431,17 @@ def timeline_render_clips(self) -> list[ClipProfile]: seg.trim_start_frame = max(0, item.in_frame) seg.trim_end_frame = max(0, item.out_frame) seg.drop_first_keyframe_override = item.drop_first_keyframe_override + # Apply per-segment glitch overrides (None = inherit the clip's value). + if item.keep_first_override is not None: + seg.keep_first = item.keep_first_override + if item.duplicate_count_override is not None: + seg.duplicate_count = item.duplicate_count_override + if item.duplicate_gap_override is not None: + seg.duplicate_gap = item.duplicate_gap_override + if item.keep_keys_spec_override is not None: + seg.keep_keys_spec = item.keep_keys_spec_override + if item.drop_keys_spec_override is not None: + seg.drop_keys_spec = item.drop_keys_spec_override out.append(seg) return out @@ -354,6 +459,7 @@ def undo(self) -> bool: self._redo_stack.append(current) self._restore_snapshot(previous) self._emit_history_changed() + self._reap_temp_dirs() return True def redo(self) -> bool: @@ -364,6 +470,7 @@ def redo(self) -> bool: self._undo_stack.append(current) self._restore_snapshot(nxt) self._emit_history_changed() + self._reap_temp_dirs() return True def _record_undo_state(self) -> None: @@ -372,6 +479,44 @@ def _record_undo_state(self) -> None: self._undo_stack.append(self._capture_snapshot()) self._redo_stack.clear() self._emit_history_changed() + self._reap_temp_dirs() + + def _referenced_clip_ids(self) -> set[int]: + """ids of clips reachable from the live list or undo/redo history.""" + ids = {id(c) for c in self.clips} + for snap in self._undo_stack: + ids.update(id(c) for c in snap.clips) + for snap in self._redo_stack: + ids.update(id(c) for c in snap.clips) + return ids + + def _reap_temp_dirs(self) -> None: + """Delete temp dirs of removed clips no longer reachable from the live list + or undo/redo history, so an undo can never resurrect deleted media.""" + if not self._pending_temp_cleanup: + return + referenced = self._referenced_clip_ids() + still_pending: list[ClipProfile] = [] + for clip in self._pending_temp_cleanup: + if id(clip) in referenced: + still_pending.append(clip) + else: + clip.cleanup() + self._pending_temp_cleanup = still_pending + + def cleanup_all(self) -> None: + """Delete every temp dir owned by this session. Call on app shutdown.""" + seen: set[int] = set() + groups: list[list[ClipProfile]] = [list(self.clips), self._pending_temp_cleanup] + groups += [snap.clips for snap in self._undo_stack] + groups += [snap.clips for snap in self._redo_stack] + for group in groups: + for clip in group: + if id(clip) in seen: + continue + seen.add(id(clip)) + clip.cleanup() + self._pending_temp_cleanup = [] def _capture_snapshot(self) -> ProjectSnapshot: clips = list(self.clips) @@ -387,19 +532,22 @@ def _capture_snapshot(self) -> ProjectSnapshot: for c in clips ] idx_map = {id(c): i for i, c in enumerate(clips)} - timeline: list[tuple[int, int, int, Optional[bool]]] = [] + timeline: list[dict] = [] for item in self._timeline: clip_idx = idx_map.get(id(item.clip)) if clip_idx is None: continue - timeline.append( - ( - clip_idx, - item.in_frame, - item.out_frame, - item.drop_first_keyframe_override, - ) - ) + timeline.append({ + "clip_idx": clip_idx, + "in_frame": item.in_frame, + "out_frame": item.out_frame, + "drop_first_keyframe_override": item.drop_first_keyframe_override, + "keep_first_override": item.keep_first_override, + "duplicate_count_override": item.duplicate_count_override, + "duplicate_gap_override": item.duplicate_gap_override, + "keep_keys_spec_override": item.keep_keys_spec_override, + "drop_keys_spec_override": item.drop_keys_spec_override, + }) return ProjectSnapshot( clips=clips, @@ -425,14 +573,20 @@ def _restore_snapshot(self, snap: ProjectSnapshot) -> None: ) = state timeline: list[TimelineItem] = [] - for clip_idx, in_frame, out_frame, drop_override in snap.timeline: + for entry in snap.timeline: + clip_idx = entry["clip_idx"] if 0 <= clip_idx < len(clips): timeline.append( TimelineItem( clip=clips[clip_idx], - in_frame=in_frame, - out_frame=out_frame, - drop_first_keyframe_override=drop_override, + in_frame=entry["in_frame"], + out_frame=entry["out_frame"], + drop_first_keyframe_override=entry["drop_first_keyframe_override"], + keep_first_override=entry["keep_first_override"], + duplicate_count_override=entry["duplicate_count_override"], + duplicate_gap_override=entry["duplicate_gap_override"], + keep_keys_spec_override=entry["keep_keys_spec_override"], + drop_keys_spec_override=entry["drop_keys_spec_override"], ) ) self._timeline = timeline @@ -461,3 +615,96 @@ def _restore_snapshot(self, snap: ProjectSnapshot) -> None: def _emit_history_changed(self) -> None: self.history_changed.emit(bool(self._undo_stack), bool(self._redo_stack)) + + # -- Project load/clear ----------------------------------------------- + + def clear(self) -> None: + """Reset to an empty project, reclaiming all temp dirs and history.""" + self.cleanup_all() + self._restoring_history = True + try: + self.clip_model.replace_clips([]) + self._timeline = [] + self._selected_row = -1 + self._selected_timeline_index = -1 + finally: + self._restoring_history = False + self._undo_stack.clear() + self._redo_stack.clear() + self._pending_temp_cleanup = [] + self.clips_changed.emit() + self.timeline_changed.emit() + self.clip_selected.emit(-1) + self.timeline_item_selected.emit(-1) + self._emit_history_changed() + + def install_loaded_state(self, data: dict) -> list[ClipProfile]: + """Rebuild clips + timeline + selection from parsed .dmosh data. + + Returns the freshly-created ClipProfile objects (not yet normalized) so the + caller can re-ingest each one. History is reset — a load is a clean slate. + """ + # Reclaim the outgoing session's temp dirs before its clips are discarded + # (clear() does this for File>New; do the same on the File>Open path). + self.cleanup_all() + clips: list[ClipProfile] = [] + for spec in data.get("clips", []): + if not isinstance(spec, dict) or "source_path" not in spec: + continue + clip = ClipProfile( + source_path=Path(spec["source_path"]), + keep_first=int(spec.get("keep_first", 0) or 0), + duplicate_count=int(spec.get("duplicate_count", 0) or 0), + duplicate_gap=max(1, int(spec.get("duplicate_gap", 1) or 1)), + drop_first_keyframe=bool(spec.get("drop_first_keyframe", False)), + keep_keys_spec=str(spec.get("keep_keys_spec", "") or ""), + drop_keys_spec=str(spec.get("drop_keys_spec", "") or ""), + source_kind=str(spec.get("kind", "clip") or "clip"), + ) + if clip.source_kind == "iframe": + clip.fps = float(spec.get("iframe_fps", 30.0) or 30.0) + clip.frame_width = int(spec.get("iframe_width") or 0) + clip.frame_height = int(spec.get("iframe_height") or 0) + clips.append(clip) + + self._restoring_history = True + try: + self.clip_model.replace_clips(clips) + timeline: list[TimelineItem] = [] + for spec in data.get("timeline", []): + if not isinstance(spec, dict): + continue + ci = spec.get("clip_index", -1) + if isinstance(ci, int) and 0 <= ci < len(clips): + opt_int = lambda v: None if v is None else int(v) + opt_str = lambda v: None if v is None else str(v) + timeline.append(TimelineItem( + clip=clips[ci], + in_frame=int(spec.get("in_frame", 0) or 0), + out_frame=int(spec.get("out_frame", 0) or 0), + drop_first_keyframe_override=spec.get("drop_first_keyframe_override"), + keep_first_override=opt_int(spec.get("keep_first_override")), + duplicate_count_override=opt_int(spec.get("duplicate_count_override")), + duplicate_gap_override=opt_int(spec.get("duplicate_gap_override")), + keep_keys_spec_override=opt_str(spec.get("keep_keys_spec_override")), + drop_keys_spec_override=opt_str(spec.get("drop_keys_spec_override")), + )) + self._timeline = timeline + sr = data.get("selected_row", -1) + self._selected_row = sr if isinstance(sr, int) and 0 <= sr < len(clips) else -1 + st = data.get("selected_timeline_index", -1) + self._selected_timeline_index = ( + st if isinstance(st, int) and 0 <= st < len(timeline) else -1 + ) + finally: + self._restoring_history = False + + self._undo_stack.clear() + self._redo_stack.clear() + self._pending_temp_cleanup = [] + self.clips_changed.emit() + self.timeline_changed.emit() + self.clip_selected.emit(self._selected_row) + self.timeline_item_selected.emit(self._selected_timeline_index) + self._emit_history_changed() + return clips diff --git a/gui/models/project_io.py b/gui/models/project_io.py new file mode 100644 index 0000000..3213447 --- /dev/null +++ b/gui/models/project_io.py @@ -0,0 +1,105 @@ +"""Serialize/deserialize a Project to a ``.dmosh`` JSON project file. + +A project stores clip *sources* and their per-clip mosh settings plus the timeline +arrangement — never the normalized/temp media (those are regenerated by re-ingesting +on load). Injected I-frame clips store their source media + format so they can be +rebuilt with the inject worker. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +PROJECT_FORMAT = "datamosh-project" +PROJECT_VERSION = 1 +PROJECT_SUFFIX = ".dmosh" + + +class ProjectLoadError(Exception): + """Raised when a .dmosh file cannot be read or is not a valid project.""" + + +def serialize(project, import_settings: dict[str, Any], app_version: str) -> dict[str, Any]: + """Build a JSON-serializable dict describing the project's editable state.""" + clips = list(project.clips) + index_of = {id(c): i for i, c in enumerate(clips)} + + clip_data: list[dict[str, Any]] = [] + for clip in clips: + entry: dict[str, Any] = { + "kind": clip.source_kind, + "source_path": str(clip.source_path), + "keep_first": clip.keep_first, + "duplicate_count": clip.duplicate_count, + "duplicate_gap": clip.duplicate_gap, + "drop_first_keyframe": clip.drop_first_keyframe, + "keep_keys_spec": clip.keep_keys_spec, + "drop_keys_spec": clip.drop_keys_spec, + } + if clip.source_kind == "iframe": + entry["iframe_fps"] = clip.fps + entry["iframe_width"] = clip.frame_width or None + entry["iframe_height"] = clip.frame_height or None + clip_data.append(entry) + + timeline_data: list[dict[str, Any]] = [] + for item in project.timeline_items: + clip_index = index_of.get(id(item.clip)) + if clip_index is None: + continue + timeline_data.append({ + "clip_index": clip_index, + "in_frame": item.in_frame, + "out_frame": item.out_frame, + "drop_first_keyframe_override": item.drop_first_keyframe_override, + "keep_first_override": item.keep_first_override, + "duplicate_count_override": item.duplicate_count_override, + "duplicate_gap_override": item.duplicate_gap_override, + "keep_keys_spec_override": item.keep_keys_spec_override, + "drop_keys_spec_override": item.drop_keys_spec_override, + }) + + return { + "format": PROJECT_FORMAT, + "version": PROJECT_VERSION, + "app_version": app_version, + "import_settings": dict(import_settings or {}), + "clips": clip_data, + "timeline": timeline_data, + "selected_row": project.selected_row, + "selected_timeline_index": project.selected_timeline_index, + } + + +def write_project(path: Path, project, import_settings: dict[str, Any], app_version: str) -> None: + """Serialize ``project`` and write it to ``path`` as pretty JSON.""" + data = serialize(project, import_settings, app_version) + Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def read_project(path: Path) -> dict[str, Any]: + """Read and validate a .dmosh file, returning its parsed dict.""" + try: + raw = Path(path).read_text(encoding="utf-8") + except OSError as exc: + raise ProjectLoadError(f"Could not open project file:\n{exc}") from exc + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ProjectLoadError(f"Project file is not valid JSON:\n{exc}") from exc + + if not isinstance(data, dict) or data.get("format") != PROJECT_FORMAT: + raise ProjectLoadError("This file is not a Datamosh project (.dmosh).") + try: + file_version = int(data.get("version", 0)) + except (TypeError, ValueError): + file_version = 0 + if file_version > PROJECT_VERSION: + raise ProjectLoadError( + "This project was saved by a newer version of Datamosh. Update the app to open it." + ) + if not isinstance(data.get("clips"), list) or not isinstance(data.get("timeline"), list): + raise ProjectLoadError("Project file is missing its clips/timeline data.") + return data diff --git a/gui/widgets/clip_panel.py b/gui/widgets/clip_panel.py index 568f295..ca78ab6 100644 --- a/gui/widgets/clip_panel.py +++ b/gui/widgets/clip_panel.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil import subprocess from pathlib import Path from typing import Any, Optional @@ -25,6 +26,7 @@ from gui.dialogs.normalize_dialog import NormalizeDialog from gui.models.clip_model import ClipProfile from gui.models.project import Project +from gui.workers.iframe_inject_worker import IFrameInjectWorker from gui.workers.normalize_worker import NormalizeWorker VIDEO_FILTER = "Video Files (*.avi *.mp4 *.mkv *.mov *.webm *.flv *.wmv);;All Files (*)" @@ -158,6 +160,36 @@ def run(self) -> None: print("[warn] ffprobe not found on PATH", flush=True) +class CodecProbeWorker(QThread): + """Probe a video's codec name off the UI thread for the direct-import check.""" + + done = Signal(int, str) # row, codec_name ("" on failure) + + def __init__(self, path: Path, row: int, parent=None) -> None: + super().__init__(parent) + self._path = path + self._row = row + + def run(self) -> None: + codec = "" + try: + result = subprocess.run( + [ + "ffprobe", "-v", "quiet", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", + str(self._path), + ], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + codec = (result.stdout.strip().splitlines() or [""])[0].strip().lower() + except Exception: + codec = "" + self.done.emit(self._row, codec) + + def _extract_thumbnail(path: Path) -> Optional[QPixmap]: """Try to grab a frame via ffmpeg, return scaled QPixmap or None.""" try: @@ -197,6 +229,7 @@ def __init__(self, project: Project, parent=None) -> None: self._project = project self._workers: list[QThread] = [] self._settings = QSettings() + self._ingest_epoch = 0 # bumped on New/Open to invalidate stale ingest callbacks self._build_ui() def _build_ui(self) -> None: @@ -251,6 +284,90 @@ def add_files(self, paths: list[str], import_settings: dict[str, Any] | None = N self._start_thumbnail(path, row) self._start_ingest(row, settings) + # -- Project load support ---------------------------------------------- + + def current_import_settings(self) -> dict[str, Any]: + """The persisted import settings (used when saving a project).""" + return self._load_import_settings() + + def cancel_ingest(self) -> None: + """Invalidate outstanding ingest callbacks before a New/Open. + + Workers keep running but their completion handlers become no-ops, so a late + normalize/probe can't write media onto a freshly-loaded clip sharing the same + row; any temp output they produce is reaped as an orphan. + """ + self._ingest_epoch += 1 + + def _discard_orphan(self, path: str) -> None: + try: + shutil.rmtree(Path(path).parent, ignore_errors=True) + except OSError: + pass + + def reingest_loaded_clip(self, row: int, settings: dict[str, Any]) -> None: + """Re-ingest a clip recreated from a loaded project so it becomes ready. + + Normal clips are re-normalized; injected I-frame clips are rebuilt from + their source media via the inject worker. + """ + clip = self._project.clip_model.clip_at(row) + if not clip: + return + if not clip.source_path.is_file(): + self._project.status_message.emit( + f"Clip {row + 1}: source not found ({clip.source_path.name})" + ) + return + if clip.source_kind == "iframe": + self._start_iframe_reingest(row, clip) + else: + self._start_thumbnail(clip.source_path, row) + self._start_ingest(row, normalize_import_settings(settings)) + + def _start_iframe_reingest(self, row: int, clip: ClipProfile) -> None: + clip.normalizing = True + self._project.clip_model.update_clip(row) + worker = IFrameInjectWorker( + source=clip.source_path, + width=clip.frame_width or None, + height=clip.frame_height or None, + fps=clip.fps, + parent=self, + ) + ep = self._ingest_epoch + worker.finished_ok.connect( + lambda out_path, out_fps, r=row, ep=ep: self._on_iframe_reingested(r, out_path, out_fps) + if ep == self._ingest_epoch else self._discard_orphan(out_path) + ) + worker.error.connect( + lambda msg, r=row, ep=ep: ep == self._ingest_epoch and self._on_reingest_error(r, msg) + ) + worker.finished.connect(worker.deleteLater) + self._track_worker(worker) + worker.start() + + def _on_iframe_reingested(self, row: int, out_path: str, out_fps: float) -> None: + clip = self._project.clip_model.clip_at(row) + if not clip: + return + out = Path(out_path) + clip.normalized_path = out + clip.temp_dir = out.parent + clip.normalizing = False + if out_fps > 0: + clip.fps = out_fps + self._project.clip_model.update_clip(row) + self._project.clips_changed.emit() + self._project.status_message.emit(f"Clip {row + 1} ready (injected I-frame)") + + def _on_reingest_error(self, row: int, msg: str) -> None: + clip = self._project.clip_model.clip_at(row) + if clip: + clip.normalizing = False + self._project.clip_model.update_clip(row) + self._project.status_message.emit(f"Error restoring clip {row + 1}: {msg}") + # -- Internals --------------------------------------------------------- def _on_item_clicked(self, index) -> None: @@ -271,7 +388,10 @@ def _remove_selected(self) -> None: def _start_thumbnail(self, path: Path, row: int) -> None: worker = ThumbnailExtractor(path, row, self) - worker.done.connect(self._on_thumbnail_ready) + ep = self._ingest_epoch + worker.done.connect( + lambda r, pix, ep=ep: ep == self._ingest_epoch and self._on_thumbnail_ready(r, pix) + ) worker.finished.connect(worker.deleteLater) self._track_worker(worker) worker.start() @@ -287,20 +407,46 @@ def _start_ingest(self, row: int, settings: dict[str, Any]) -> None: if not clip: return - if self._should_use_source_directly(clip.source_path, settings): + # Only "prefer source" mode can skip normalization, and only for AVI input. + # Probe the codec on a worker thread (this used to block the UI thread for + # up to 5s) and decide direct-vs-normalize once the result arrives. + if ( + settings.get("mode") == IMPORT_MODE_PREFER_SOURCE + and clip.source_path.suffix.lower() == ".avi" + ): + clip.normalizing = True + self._project.clip_model.update_clip(row) + self._start_codec_probe(row, clip.source_path, settings) + return + + self._start_normalize(row, settings) + + def _start_codec_probe(self, row: int, path: Path, settings: dict[str, Any]) -> None: + worker = CodecProbeWorker(path, row, self) + ep = self._ingest_epoch + worker.done.connect( + lambda r, codec, ep=ep: ep == self._ingest_epoch and self._on_codec_probed(r, codec, settings) + ) + worker.finished.connect(worker.deleteLater) + self._track_worker(worker) + worker.start() + + def _on_codec_probed(self, row: int, codec: str, settings: dict[str, Any]) -> None: + clip = self._project.clip_model.clip_at(row) + if not clip: + return + if codec in DIRECT_IMPORT_CODECS: clip.normalized_path = clip.source_path clip.normalizing = False self._project.clip_model.update_clip(row) self._project.clips_changed.emit() self._project.status_message.emit(f"Clip {row + 1} ready (source AVI)") self._start_video_info(row, clip.source_path) - return - - if settings.get("mode") == IMPORT_MODE_PREFER_SOURCE: + else: self._project.status_message.emit( f"Clip {row + 1}: source not compatible for direct import, normalizing..." ) - self._start_normalize(row, settings) + self._start_normalize(row, settings) def _start_normalize(self, row: int, settings: dict[str, Any]) -> None: clip = self._project.clip_model.clip_at(row) @@ -318,9 +464,15 @@ def _start_normalize(self, row: int, settings: dict[str, Any]) -> None: gop=cfg["gop"], keep_audio=cfg["keep_audio"], ) + ep = self._ingest_epoch worker.progress.connect(self._on_normalize_progress) - worker.finished_ok.connect(self._on_normalize_done) - worker.error.connect(self._on_normalize_error) + worker.finished_ok.connect( + lambda r, path, ep=ep: self._on_normalize_done(r, path) + if ep == self._ingest_epoch else self._discard_orphan(path) + ) + worker.error.connect( + lambda r, msg, ep=ep: ep == self._ingest_epoch and self._on_normalize_error(r, msg) + ) worker.finished.connect(worker.deleteLater) clip.normalizing = True self._project.clip_model.update_clip(row) @@ -351,7 +503,11 @@ def _on_normalize_error(self, row: int, msg: str) -> None: def _start_video_info(self, row: int, path: Path) -> None: worker = VideoInfoWorker(path, row, self) - worker.done.connect(self._on_video_info_ready) + ep = self._ingest_epoch + worker.done.connect( + lambda r, tf, fps, w, h, ep=ep: ep == self._ingest_epoch + and self._on_video_info_ready(r, tf, fps, w, h) + ) worker.finished.connect(worker.deleteLater) self._track_worker(worker) worker.start() @@ -395,31 +551,6 @@ def _save_import_settings(self, settings: dict[str, Any]) -> None: self._settings.setValue(f"{SETTINGS_PREFIX}{key}", value) self._settings.sync() - @staticmethod - def _should_use_source_directly(path: Path, settings: dict[str, Any]) -> bool: - if settings.get("mode") != IMPORT_MODE_PREFER_SOURCE: - return False - if path.suffix.lower() != ".avi": - return False - - try: - result = subprocess.run( - [ - "ffprobe", "-v", "quiet", - "-select_streams", "v:0", - "-show_entries", "stream=codec_name", - "-of", "default=noprint_wrappers=1:nokey=1", - str(path), - ], - capture_output=True, text=True, timeout=5, - ) - if result.returncode != 0: - return False - codec = (result.stdout.strip().splitlines() or [""])[0].strip().lower() - return codec in DIRECT_IMPORT_CODECS - except Exception: - return False - def _track_worker(self, worker: QThread) -> None: self._workers.append(worker) worker.finished.connect(lambda w=worker: self._forget_worker(w)) diff --git a/gui/widgets/preview_widget.py b/gui/widgets/preview_widget.py index d68ff14..f5645e4 100644 --- a/gui/widgets/preview_widget.py +++ b/gui/widgets/preview_widget.py @@ -50,6 +50,8 @@ def __init__(self, project: Project, parent=None) -> None: self._mosh_worker: Optional[MoshWorker] = None self._extractor: Optional[FrameExtractor] = None + self._generation = 0 # bumps on every re-mosh; gates stale callbacks + self._inflight: set = set() # running QThreads awaiting clean shutdown self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(300) @@ -101,6 +103,8 @@ def _build_ui(self) -> None: self._scrubber = QSlider(Qt.Orientation.Horizontal) self._scrubber.setRange(0, 0) self._scrubber.valueChanged.connect(self._on_scrub) + # Re-apply the range/label once a drag ends (updates are skipped mid-drag). + self._scrubber.sliderReleased.connect(self._update_scrub_range) controls.addWidget(self._scrubber, 1) self._frame_label = QLabel("0/0") @@ -204,67 +208,139 @@ def _do_live_mosh(self) -> None: return self._last_mosh_id = mosh_id - # Cancel any in-flight work + # Supersede in-flight work and start a new generation. Each generation + # writes to its own temp file and its callbacks are gated by the token, so a + # detached worker can never mix frames into, or clobber, the current preview. self._cancel_workers() - - temp_out = self._temp_dir / "preview.avi" - - self._mosh_worker = MoshWorker(clips, temp_out, self) - self._mosh_worker.finished_ok.connect(self._on_mosh_done) - self._mosh_worker.error.connect(self._on_mosh_error) + gen = self._generation + self._purge_preview_files(keep_gen=gen) + + temp_out = self._temp_dir / f"preview_{gen}.avi" + worker = MoshWorker(clips, temp_out, self) + worker.finished_ok.connect(lambda path, g=gen: self._on_mosh_done(g, path)) + worker.error.connect(lambda msg, g=gen: self._on_mosh_error(g, msg)) + worker.finished.connect(lambda w=worker: self._retire_worker(w)) + self._mosh_worker = worker + self._inflight.add(worker) self._preview_max_frames = self._estimate_preview_frame_count(clips) - self._mosh_worker.start() + worker.start() name = "timeline" if self._preview_all else clips[0].label() self._display.setText(f"Moshing {name}...") - def _on_mosh_done(self, path: str) -> None: - self._frames.clear() + def _on_mosh_done(self, gen: int, path: str) -> None: + if gen != self._generation: + return # superseded by a newer re-mosh + self._frames = [] self._current_frame = 0 - self._extractor = FrameExtractor( + extractor = FrameExtractor( Path(path), self, max_frames=self._preview_max_frames, target_width=640, ) - self._extractor.frame_ready.connect(self._on_frame_decoded) - self._extractor.all_done.connect(self._on_extraction_done) - self._extractor.error.connect(self._on_mosh_error) - self._extractor.start() - - def _on_frame_decoded(self, idx: int, img: QImage) -> None: + extractor.frame_ready.connect(lambda idx, img, g=gen: self._on_frame_decoded(g, idx, img)) + extractor.all_done.connect(lambda total, g=gen: self._on_extraction_done(g, total)) + extractor.error.connect(lambda msg, g=gen: self._on_mosh_error(g, msg)) + extractor.finished.connect(lambda w=extractor: self._retire_worker(w)) + self._extractor = extractor + self._inflight.add(extractor) + extractor.start() + + def _on_frame_decoded(self, gen: int, idx: int, img: QImage) -> None: + if gen != self._generation: + return self._frames.append(img) if idx == 0: self._show_image(img) - self._scrubber.setRange(0, max(0, len(self._frames) - 1)) - self._frame_label.setText(f"{self._current_frame + 1}/{len(self._frames)}") + self._update_scrub_range() - def _on_extraction_done(self, total: int) -> None: + def _on_extraction_done(self, gen: int, total: int) -> None: + if gen != self._generation: + return if self._frames: self.show_frame(min(self._current_frame, len(self._frames) - 1)) - self._scrubber.setRange(0, max(0, len(self._frames) - 1)) + self._update_scrub_range() - def _on_mosh_error(self, msg: str) -> None: + def _on_mosh_error(self, gen: int, msg: str) -> None: + if gen != self._generation: + return self._display.setText(f"Preview error: {msg}") self._last_mosh_id = None # allow retry def _cancel_workers(self) -> None: - if self._extractor and self._extractor.isRunning(): + # Bump the generation so any outstanding worker callbacks become no-ops, + # then cooperatively abort both the extractor and the mosh worker (both poll + # an _abort flag and stop quickly). Nothing is terminate()'d here: a detached + # worker stops at its next checkpoint and self-retires via `finished`. + self._generation += 1 + if self._extractor is not None: self._extractor.abort() - if not self._extractor.wait(1000): - self._extractor.terminate() - self._extractor.wait(300) - if self._mosh_worker and self._mosh_worker.isRunning(): - if not self._mosh_worker.wait(2000): - self._mosh_worker.terminate() - self._mosh_worker.wait(300) + self._extractor = None + if self._mosh_worker is not None: + self._mosh_worker.abort() + self._mosh_worker = None + + def _retire_worker(self, worker) -> None: + self._inflight.discard(worker) + worker.deleteLater() + + def _purge_preview_files(self, keep_gen: Optional[int] = None) -> None: + keep = f"preview_{keep_gen}.avi" if keep_gen is not None else None + for f in self._temp_dir.glob("preview_*.avi"): + if keep is not None and f.name == keep: + continue + try: + f.unlink() + except OSError: + pass # likely still being written by a detached worker; retry later + + def _update_scrub_range(self) -> None: + if self._scrubber.isSliderDown(): + return # don't fight an active scrub-drag + self._scrubber.blockSignals(True) + self._scrubber.setRange(0, max(0, len(self._frames) - 1)) + self._scrubber.blockSignals(False) + self._frame_label.setText(f"{self._current_frame + 1}/{len(self._frames)}") + + def reset(self) -> None: + """Cancel in-flight work and clear the display (project new/load). + + Waits for the aborted workers to actually finish so the caller can safely + reclaim clip temp dirs (no rmtree-while-reading race). + """ + self._debounce.stop() + self._stop_playback() + self._cancel_workers() + for worker in list(self._inflight): + if worker.isRunning(): + worker.wait(2000) + self._frames = [] + self._current_frame = 0 + self._last_mosh_id = None + self._scrubber.blockSignals(True) + self._scrubber.setRange(0, 0) + self._scrubber.setValue(0) + self._scrubber.blockSignals(False) + self._frame_label.setText("0/0") + self._display.setPixmap(QPixmap()) + self._display.setText("Drop or open a video file to begin") def shutdown(self) -> None: """Stop preview timers/workers and clean temp files.""" self._debounce.stop() self._stop_playback() self._cancel_workers() + # Wait for aborted workers to finish so no QThread is destroyed while still + # running. As a last resort at process exit only, terminate a worker that + # won't stop (e.g. stuck in a large write) — acceptable since we rmtree the + # temp dir immediately after. + for worker in list(self._inflight): + if worker.isRunning() and not worker.wait(3000): + worker.terminate() + worker.wait(500) + self._inflight.clear() shutil.rmtree(self._temp_dir, ignore_errors=True) # -- Playback controls ------------------------------------------------- diff --git a/gui/widgets/settings_panel.py b/gui/widgets/settings_panel.py index 3ef18a3..945e99e 100644 --- a/gui/widgets/settings_panel.py +++ b/gui/widgets/settings_panel.py @@ -171,17 +171,20 @@ def load_clip(self, row: int) -> None: self._set_controls_enabled(True) self._refresh_clip_label(clip.label()) + # When a timeline segment is selected, show its effective (per-segment) values. + eff = self._effective_settings(clip) + # Block signals while populating for w in (self._keep_first.spin, self._dup_count.spin, self._dup_gap.spin, self._drop_first_kf, self._keep_keys, self._drop_keys): w.blockSignals(True) - self._keep_first.set_value(clip.keep_first) - self._dup_count.set_value(clip.duplicate_count) - self._dup_gap.set_value(clip.duplicate_gap) + self._keep_first.set_value(eff["keep_first"]) + self._dup_count.set_value(eff["duplicate_count"]) + self._dup_gap.set_value(eff["duplicate_gap"]) self._drop_first_kf.setChecked(self._effective_drop_first_for_ui(clip)) - self._keep_keys.setText(clip.keep_keys_spec) - self._drop_keys.setText(clip.drop_keys_spec) + self._keep_keys.setText(eff["keep_keys_spec"]) + self._drop_keys.setText(eff["drop_keys_spec"]) for w in (self._keep_first.spin, self._dup_count.spin, self._dup_gap.spin, self._drop_first_kf, self._keep_keys, self._drop_keys): @@ -206,17 +209,60 @@ def _on_drop_first_changed(self) -> None: self._debounce.stop() self._apply_drop_first() + def _effective_settings(self, clip) -> dict: + """Effective glitch values: the selected segment's override if set, else the + clip's value. With no segment selected, just the clip's values.""" + seg = None + if self._timeline_item_matches_current_clip(): + seg = self._project.timeline_items[self._current_timeline_index] + + def pick(override, base): + return base if override is None else override + + if seg is None: + return { + "keep_first": clip.keep_first, + "duplicate_count": clip.duplicate_count, + "duplicate_gap": clip.duplicate_gap, + "keep_keys_spec": clip.keep_keys_spec, + "drop_keys_spec": clip.drop_keys_spec, + } + return { + "keep_first": pick(seg.keep_first_override, clip.keep_first), + "duplicate_count": pick(seg.duplicate_count_override, clip.duplicate_count), + "duplicate_gap": pick(seg.duplicate_gap_override, clip.duplicate_gap), + "keep_keys_spec": pick(seg.keep_keys_spec_override, clip.keep_keys_spec), + "drop_keys_spec": pick(seg.drop_keys_spec_override, clip.drop_keys_spec), + } + def _push_settings(self) -> None: clip = self._project.clip_model.clip_at(self._current_row) if not clip: return self._project.begin_undo_step() - clip.keep_first = self._keep_first.value() - clip.duplicate_count = self._dup_count.value() - clip.duplicate_gap = self._dup_gap.value() + keep_first = self._keep_first.value() + dup_count = self._dup_count.value() + dup_gap = self._dup_gap.value() + keep_keys = self._keep_keys.text().strip() + drop_keys = self._drop_keys.text().strip() + if self._timeline_item_matches_current_clip(): + # Per-segment: store overrides on the selected timeline item. + self._project.update_timeline_item_settings( + self._current_timeline_index, + keep_first=keep_first, + duplicate_count=dup_count, + duplicate_gap=dup_gap, + keep_keys_spec=keep_keys, + drop_keys_spec=drop_keys, + record_undo=False, + ) + else: + clip.keep_first = keep_first + clip.duplicate_count = dup_count + clip.duplicate_gap = dup_gap + clip.keep_keys_spec = keep_keys + clip.drop_keys_spec = drop_keys self._apply_drop_first(record_undo=False, emit_notify=False) - clip.keep_keys_spec = self._keep_keys.text().strip() - clip.drop_keys_spec = self._drop_keys.text().strip() self._project.notify_clip_updated(self._current_row, record_undo=False) def _apply_drop_first(self, *, record_undo: bool = True, emit_notify: bool = True) -> None: diff --git a/gui/widgets/timeline_widget.py b/gui/widgets/timeline_widget.py index a56134f..c2bbbe2 100644 --- a/gui/widgets/timeline_widget.py +++ b/gui/widgets/timeline_widget.py @@ -95,7 +95,8 @@ class ClipRegion: clip_row: int label: str frames: list[FrameInfo] = field(default_factory=list) - frame_count: int = 1 + frame_count: int = 1 # predicted OUTPUT frame count (after moshing) + source_frame_count: int = 1 # trimmed SOURCE frame count (before moshing) keep_first: int = 0 dup_count: int = 0 dup_gap: int = 1 @@ -145,9 +146,18 @@ def __init__(self, parent=None) -> None: self._pps: float = 100.0 self._scroll_sec: float = 0.0 - self._font = QFont("Sans", 9) - self._font_small = QFont("Sans", 7) - self._font_bold = QFont("Sans", 9, QFont.Weight.Bold) + # "Sans" is not a real family on Windows/macOS and triggers font + # substitution; build from the platform default with a sans-serif hint. + self._font = QFont() + self._font.setPointSize(9) + self._font.setStyleHint(QFont.StyleHint.SansSerif) + self._font_small = QFont() + self._font_small.setPointSize(7) + self._font_small.setStyleHint(QFont.StyleHint.SansSerif) + self._font_bold = QFont() + self._font_bold.setPointSize(9) + self._font_bold.setBold(True) + self._font_bold.setStyleHint(QFont.StyleHint.SansSerif) # -- Public API -------------------------------------------------------- @@ -223,11 +233,16 @@ def current_playhead_location(self) -> tuple[int, int]: right = t_offset + region.duration_sec if self._playhead_sec < right or idx == len(self._regions) - 1: local_sec = max(0.0, min(self._playhead_sec - t_offset, region.duration_sec)) - if region.fps > 0: - local_frame = int(local_sec * region.fps) - else: - local_frame = int((local_sec / max(region.duration_sec, 1e-6)) * region.frame_count) - local_frame = max(0, min(local_frame, max(0, region.frame_count - 1))) + # Map the playhead to a SOURCE frame. duration_sec reflects the + # predicted OUTPUT length, so deriving a frame from fps/output count + # gives an OUTPUT index — but cuts and inserts operate on SOURCE + # frames (project.split_timeline_item). With duplication active the + # output count is inflated, so an output index would clamp a midpoint + # cut to the end of the source. Use the time fraction × source count. + src_n = max(1, region.source_frame_count) + frac = local_sec / region.duration_sec if region.duration_sec > 0 else 0.0 + local_frame = int(frac * src_n) + local_frame = max(0, min(local_frame, src_n - 1)) return region.timeline_index, local_frame t_offset = right @@ -268,6 +283,29 @@ def _clip_at_sec(self, sec: float) -> tuple[int, ClipRegion | None]: t += r.duration_sec return -1, None + def _snap_sec_to_frame(self, sec: float) -> float: + """Snap a timeline second to ~1/3 into the frame it lands on. + + Keeps the playhead clearly *inside* one frame (so it's obvious which frame is + shown) and left-of-centre (so it's obvious which side a cut falls on). Outside + any clip the value is just clamped. + """ + sec = max(0.0, min(sec, self._total_duration)) + if sec <= 0 or not self._regions: + return sec + t = 0.0 + for r in self._regions: + if t <= sec < t + r.duration_sec: + n = max(1, r.frame_count) + frame_dur = r.duration_sec / n + if frame_dur <= 0: + return sec + idx = int((sec - t) / frame_dur) + idx = max(0, min(idx, n - 1)) + return t + (idx + 0.3) * frame_dur + t += r.duration_sec + return sec + def _insert_index_for_sec(self, sec: float) -> int: if not self._regions: return 0 @@ -482,43 +520,55 @@ def _draw_frame_viz( px_per_frame = clip_w / n if px_per_frame >= 3: + # Lay frames out proportional to their OUTPUT contribution, so only the + # frames that are actually duplicated look longer and kept I-frames stay + # normal width: a duplicated P-frame spans (1 + dup_count) output frames, + # a normal frame spans 1, and a dropped keyframe spans 0 (thin marker). + keep_limit = _effective_keep_limit(r.keep_first, r.drop_first) + spans: list[tuple[FrameInfo, int, bool]] = [] # (frame, output_span, is_dup) key_count = 0 p_count = 0 - keep_limit = _effective_keep_limit(r.keep_first, r.drop_first) - for i, frame in enumerate(r.frames): - fx = x_offset + i * px_per_frame - if fx > x_offset + clip_w: + for frame in r.frames: + is_dup = False + if frame.is_keyframe: + kept = key_count < keep_limit and not (r.drop_first and key_count == 0) + key_count += 1 + span = 1 if kept else 0 + else: + p_count += 1 + is_dup = r.dup_count > 0 and r.dup_gap > 0 and (p_count % r.dup_gap == 0) + span = 1 + (r.dup_count if is_dup else 0) + spans.append((frame, span, is_dup)) + + total_span = sum(s for _f, s, _d in spans) or 1 + px_per_unit = clip_w / total_span + x = x_offset + for frame, span, is_dup in spans: + if span <= 0: + # Dropped keyframe: thin marker, takes no output width. + p.fillRect(QRectF(x, y_top, 2.0, height), DROPPED_KEY) + continue + if x > x_offset + clip_w: break - + seg_w = span * px_per_unit if frame.is_keyframe: - keep_key = key_count < keep_limit - if r.drop_first and key_count == 0: - keep_key = False - color = KEPT_KEY if keep_key else DROPPED_KEY + color = KEPT_KEY bar_h = height - key_count += 1 else: color = P_FRAME bar_h = height * 0.45 - p_count += 1 - fy = y_top + (height - bar_h) - bar_w = max(px_per_frame - 1, 1.5) - - if px_per_frame >= 5: + bar_w = max(seg_w - 1, 1.5) + if px_per_unit >= 5: bp = QPainterPath() - bp.addRoundedRect(QRectF(fx, fy, bar_w, bar_h), 1.5, 1.5) + bp.addRoundedRect(QRectF(x, fy, bar_w, bar_h), 1.5, 1.5) p.fillPath(bp, color) else: - p.fillRect(QRectF(fx, fy, bar_w, bar_h), color) - - if ( - not frame.is_keyframe - and r.dup_count > 0 - and r.dup_gap > 0 - and p_count % r.dup_gap == 0 - ): - p.fillRect(QRectF(fx, y_top, max(bar_w, 2), 3), DUP_MARK) + p.fillRect(QRectF(x, fy, bar_w, bar_h), color) + if is_dup: + # Orange marker spanning the whole (widened) duplicated frame. + p.fillRect(QRectF(x, y_top, max(bar_w, 2), 3), DUP_MARK) + x += seg_w else: buckets = max(1, int(clip_w / 2)) frames_per_bucket = max(1, n / buckets) @@ -610,7 +660,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: if y < RULER_H: self._dragging_playhead = True - self._playhead_sec = max(0.0, min(sec, self._total_duration)) + self._playhead_sec = self._snap_sec_to_frame(sec) self._emit_frame_for_sec(self._playhead_sec) self.update() return @@ -619,7 +669,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: return item_idx, region = self._clip_at_sec(sec) - self._playhead_sec = max(0.0, min(sec, self._total_duration)) + self._playhead_sec = self._snap_sec_to_frame(sec) self._emit_frame_for_sec(self._playhead_sec) if item_idx >= 0 and region is not None: @@ -657,7 +707,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: if self._dragging_playhead: sec = self._x_to_sec(event.position().x()) - self._playhead_sec = max(0.0, min(sec, self._total_duration)) + self._playhead_sec = self._snap_sec_to_frame(sec) self._emit_frame_for_sec(self._playhead_sec) self.update() return @@ -893,6 +943,9 @@ def _build_ui(self) -> None: self._delete_shortcut = QShortcut(QKeySequence("Delete"), self) self._delete_shortcut.activated.connect(self._delete_selected) + # macOS: the primary delete key is Backspace ("Delete" maps to Fn+Delete there). + self._backspace_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Backspace), self) + self._backspace_shortcut.activated.connect(self._delete_selected) self._inject_shortcut = QShortcut(QKeySequence("Ctrl+Shift+I"), self) self._inject_shortcut.activated.connect(self._inject_iframe_from_media) @@ -959,11 +1012,17 @@ def refresh(self) -> None: source_frame_count = frame_count frames: list[FrameInfo] = [] loading = not clip.is_ready() - drop_first = ( - item.drop_first_keyframe_override - if item.drop_first_keyframe_override is not None - else clip.drop_first_keyframe - ) + # Effective per-segment glitch settings (override if set, else the clip's), + # so the timeline viz/duration reflect what this segment will actually mosh. + def _eff(override, base): + return base if override is None else override + + drop_first = _eff(item.drop_first_keyframe_override, clip.drop_first_keyframe) + eff_keep_first = _eff(item.keep_first_override, clip.keep_first) + eff_dup_count = _eff(item.duplicate_count_override, clip.duplicate_count) + eff_dup_gap = _eff(item.duplicate_gap_override, clip.duplicate_gap) + eff_keep_keys = _eff(item.keep_keys_spec_override, clip.keep_keys_spec) + eff_drop_keys = _eff(item.drop_keys_spec_override, clip.drop_keys_spec) full = self._frame_cache.get(key) if full is not None: @@ -974,19 +1033,19 @@ def refresh(self) -> None: source_frame_count = max(1, len(frames)) frame_count = self._predict_output_frame_count( frames, - keep_first=clip.keep_first, + keep_first=eff_keep_first, drop_first=drop_first, - dup_count=clip.duplicate_count, - dup_gap=clip.duplicate_gap, - keep_keys_spec=clip.keep_keys_spec, - drop_keys_spec=clip.drop_keys_spec, + dup_count=eff_dup_count, + dup_gap=eff_dup_gap, + keep_keys_spec=eff_keep_keys, + drop_keys_spec=eff_drop_keys, ) loading = False else: frame_count = self._estimate_output_frames_without_analysis( source_frame_count, - dup_count=clip.duplicate_count, - dup_gap=clip.duplicate_gap, + dup_count=eff_dup_count, + dup_gap=eff_dup_gap, ) fps = clip.fps if clip.fps > 0 else 30.0 @@ -998,9 +1057,10 @@ def refresh(self) -> None: label=clip.label(), frames=frames, frame_count=frame_count, - keep_first=clip.keep_first, - dup_count=clip.duplicate_count, - dup_gap=clip.duplicate_gap, + source_frame_count=source_frame_count, + keep_first=eff_keep_first, + dup_count=eff_dup_count, + dup_gap=eff_dup_gap, drop_first=drop_first, fps=fps, duration_sec=max(0.01, duration), @@ -1247,6 +1307,7 @@ def _on_iframe_injected( fps=out_fps if out_fps > 0 else 30.0, frame_width=width or 0, frame_height=height or 0, + source_kind="iframe", temp_dir=generated_path.parent, ) self._project.begin_undo_step() diff --git a/gui/widgets/toolbar.py b/gui/widgets/toolbar.py index 4fb1566..6f57343 100644 --- a/gui/widgets/toolbar.py +++ b/gui/widgets/toolbar.py @@ -5,6 +5,13 @@ from PySide6.QtWidgets import QStyle, QToolBar +def _tip(text: str, action: QAction) -> str: + """Append the action's shortcut in platform-native form (e.g. ⌘O on macOS, + Ctrl+O on Windows/Linux) so tooltips never show the wrong modifier.""" + seq = action.shortcut().toString(QKeySequence.SequenceFormat.NativeText) + return f"{text} ({seq})" if seq else text + + class Toolbar(QToolBar): open_clicked = Signal() add_clip_clicked = Signal() @@ -24,13 +31,13 @@ def __init__(self, parent=None) -> None: self._open = QAction(style.standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton), "Open", self) self._open.setShortcut(QKeySequence("Ctrl+O")) - self._open.setToolTip("Open video file(s) (Ctrl+O)") + self._open.setToolTip(_tip("Open video file(s)", self._open)) self._open.triggered.connect(self.open_clicked) self.addAction(self._open) self._add = QAction(style.standardIcon(QStyle.StandardPixmap.SP_FileIcon), "Add Clip", self) self._add.setShortcut(QKeySequence("Ctrl+Shift+O")) - self._add.setToolTip("Add another clip (Ctrl+Shift+O)") + self._add.setToolTip(_tip("Add another clip", self._add)) self._add.triggered.connect(self.add_clip_clicked) self.addAction(self._add) @@ -38,7 +45,7 @@ def __init__(self, parent=None) -> None: self._render = QAction(style.standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton), "Render", self) self._render.setShortcut(QKeySequence("Ctrl+R")) - self._render.setToolTip("Render moshed output (Ctrl+R)") + self._render.setToolTip(_tip("Render moshed output", self._render)) self._render.triggered.connect(self.render_clicked) self.addAction(self._render) @@ -46,14 +53,14 @@ def __init__(self, parent=None) -> None: self._undo = QAction(style.standardIcon(QStyle.StandardPixmap.SP_ArrowBack), "Undo", self) self._undo.setShortcut(QKeySequence.StandardKey.Undo) - self._undo.setToolTip("Undo timeline/settings change (Ctrl+Z)") + self._undo.setToolTip(_tip("Undo timeline/settings change", self._undo)) self._undo.setEnabled(False) self._undo.triggered.connect(self.undo_clicked) self.addAction(self._undo) self._redo = QAction(style.standardIcon(QStyle.StandardPixmap.SP_ArrowForward), "Redo", self) self._redo.setShortcut(QKeySequence.StandardKey.Redo) - self._redo.setToolTip("Redo timeline/settings change (Ctrl+Shift+Z)") + self._redo.setToolTip(_tip("Redo timeline/settings change", self._redo)) self._redo.setEnabled(False) self._redo.triggered.connect(self.redo_clicked) self.addAction(self._redo) @@ -69,7 +76,7 @@ def __init__(self, parent=None) -> None: self._help = QAction(style.standardIcon(QStyle.StandardPixmap.SP_DialogHelpButton), "Help", self) self._help.setShortcut(QKeySequence("F1")) - self._help.setToolTip("Keyboard shortcuts (F1)") + self._help.setToolTip(_tip("Keyboard shortcuts", self._help)) self._help.triggered.connect(self.help_clicked) self.addAction(self._help) diff --git a/gui/workers/iframe_inject_worker.py b/gui/workers/iframe_inject_worker.py index 1fd488c..cbb16d0 100644 --- a/gui/workers/iframe_inject_worker.py +++ b/gui/workers/iframe_inject_worker.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil import tempfile from pathlib import Path import subprocess @@ -33,6 +34,7 @@ def __init__( self._qscale = max(1, int(qscale)) def run(self) -> None: + tmp_dir: Path | None = None try: tmp_dir = Path(tempfile.mkdtemp(prefix="datamosh-inject-")) out_path = tmp_dir / f"{self._source.stem}_inject_iframe.avi" @@ -84,4 +86,7 @@ def run(self) -> None: raise RuntimeError(details or "ffmpeg failed to build inject clip") self.finished_ok.emit(str(out_path), float(self._fps)) except Exception as exc: + # Don't leak the temp dir in %TEMP%/tmp when the build fails. + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) self.error.emit(str(exc)) diff --git a/gui/workers/mosh_worker.py b/gui/workers/mosh_worker.py index 24bcf0d..56f3b04 100644 --- a/gui/workers/mosh_worker.py +++ b/gui/workers/mosh_worker.py @@ -2,9 +2,16 @@ from __future__ import annotations +import shutil +import struct +import subprocess +import tempfile from pathlib import Path from typing import Dict, Optional, Set +# Containers we export by re-encoding the moshed AVI (engine output is always AVI). +TRANSCODE_SUFFIXES = (".mp4", ".mov") + from PySide6.QtCore import QThread, Signal import mosh @@ -82,6 +89,11 @@ def __init__( super().__init__(parent) self._clips = clips self._output = output_path + self._abort = False + + def abort(self) -> None: + """Request cooperative cancellation; rewrite stops at the next chunk check.""" + self._abort = True def run(self) -> None: try: @@ -101,23 +113,59 @@ def run(self) -> None: extras = ready[1:] clip_opts = build_clip_options(ready) - if any(_clip_has_trim(c) for c in ready): - self._rewrite_with_trims(ready, clip_opts) - else: - mosh.rewrite_avi( - base.normalized_path, - self._output, - keep_initial_keyframes=clip_opts[0].keep_initial_keyframes, - duplicate_count=clip_opts[0].duplicate_count, - duplicate_gap=clip_opts[0].duplicate_gap, - extra_inputs=[c.normalized_path for c in extras], - keep_key_indices=None, - drop_key_indices=None, - clip_options=clip_opts, - drop_appended_first=False, - ) + # The engine only writes AVI; for MP4/MOV we mosh to a temp AVI then + # transcode (re-encoding the glitched frames into a clean shareable file). + transcode = self._output.suffix.lower() in TRANSCODE_SUFFIXES + tmp_dir: Optional[Path] = None + avi_target = self._output + if transcode: + tmp_dir = Path(tempfile.mkdtemp(prefix="datamosh-export-")) + avi_target = tmp_dir / "moshed.avi" + + try: + if any(_clip_has_trim(c) for c in ready): + self._rewrite_with_trims(ready, clip_opts, avi_target) + else: + mosh.rewrite_avi( + base.normalized_path, + avi_target, + keep_initial_keyframes=clip_opts[0].keep_initial_keyframes, + duplicate_count=clip_opts[0].duplicate_count, + duplicate_gap=clip_opts[0].duplicate_gap, + extra_inputs=[c.normalized_path for c in extras], + keep_key_indices=None, + drop_key_indices=None, + clip_options=clip_opts, + drop_appended_first=False, + should_abort=lambda: self._abort, + ) + if self._abort: + return + if transcode: + self._transcode(avi_target, self._output) + finally: + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) self.finished_ok.emit(str(self._output)) + except mosh.MoshAborted: + return # superseded/cancelled — emit nothing + except struct.error as exc: + # mosh.py packs the movi/RIFF sizes as unsigned 32-bit; a very large + # mosh (high duplicate count or long clips) overflows that and raises a + # cryptic struct.error ("'I' format requires 0 <= number <= ..."). The + # engine is treated as untouched, so translate that case here. A + # struct.error can also come from parsing a truncated/corrupt AVI + # (unpack_from), so only the integer-overflow signature maps to the + # size-limit message; anything else is reported as-is. + msg = str(exc).lower() + if "format requires" in msg or "argument out of range" in msg or "requires 0 <=" in msg: + self.error.emit( + "Output is too large for the AVI format (~4 GB limit). Reduce the " + "duplicate count, increase the duplicate gap, or shorten the clips." + ) + else: + self.error.emit(f"Failed to read or write video data: {exc}") except Exception as exc: self.error.emit(str(exc)) @@ -125,6 +173,7 @@ def _rewrite_with_trims( self, clips: list[ClipProfile], clip_opts: Dict[int, mosh.ClipOptions], + output: Path, ) -> None: """Rewrite using timeline segment trims without re-encoding.""" parsed_cache: dict[Path, mosh.AviStructure] = {} @@ -168,6 +217,7 @@ def _rewrite_with_trims( drop_key_indices=None, clip_options=clip_opts, drop_appended_first=False, + should_abort=lambda: self._abort, ) movi_chunk, idx_chunk, video_frames = mosh.build_movi_and_index(processed) @@ -183,4 +233,27 @@ def _rewrite_with_trims( rebuilt += base_struct.suffix mosh.pack_le_uint(rebuilt, 4, len(rebuilt) - 8) - self._output.write_bytes(bytes(rebuilt)) + output.write_bytes(bytes(rebuilt)) + + def _transcode(self, src_avi: Path, dst: Path) -> None: + """Re-encode the moshed AVI into the container implied by dst's suffix. + + Decoding the broken-prediction AVI yields the glitched frames, which we + re-encode cleanly to H.264 so the export plays anywhere. + """ + cmd = [ + "ffmpeg", "-y", "-i", str(src_avi), + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", + str(dst), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError as exc: + raise RuntimeError("ffmpeg not found on PATH; cannot export to this format.") from exc + if result.returncode != 0: + tail = "\n".join((result.stderr or "").strip().splitlines()[-6:]) + raise RuntimeError( + f"Export transcode failed (ffmpeg exit {result.returncode})." + + (f"\n\n{tail}" if tail else "") + ) diff --git a/gui/workers/normalize_worker.py b/gui/workers/normalize_worker.py index 5ec22ea..1b719bd 100644 --- a/gui/workers/normalize_worker.py +++ b/gui/workers/normalize_worker.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil import tempfile from pathlib import Path @@ -41,6 +42,7 @@ def __init__( self._keep_audio = keep_audio def run(self) -> None: + tmp_dir: Path | None = None try: self.progress.emit(self._row, 0) @@ -70,4 +72,7 @@ def run(self) -> None: self.finished_ok.emit(self._row, str(out_path)) except Exception as exc: + # Don't leak the temp dir in %TEMP%/tmp when normalization fails. + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) self.error.emit(self._row, str(exc)) diff --git a/launch.bat b/launch.bat new file mode 100644 index 0000000..a70aa6f --- /dev/null +++ b/launch.bat @@ -0,0 +1,30 @@ +@echo off +REM Launcher script for Datamosh GUI (PySide6) on Windows. +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" + +if not exist "%SCRIPT_DIR%main.py" ( + echo Error: main.py not found in %SCRIPT_DIR% 1>&2 + exit /b 1 +) + +where python >nul 2>&1 +if errorlevel 1 ( + echo Error: python not found. Install Python 3.10+ from python.org. 1>&2 + exit /b 1 +) + +where ffmpeg >nul 2>&1 +if errorlevel 1 echo Warning: ffmpeg not found in PATH. Normalization/render may fail. 1>&2 + +where ffprobe >nul 2>&1 +if errorlevel 1 echo Warning: ffprobe not found in PATH. Timeline frame analysis may fail. 1>&2 + +python -c "import PySide6" >nul 2>&1 +if errorlevel 1 ( + echo Error: PySide6 is not installed. Run: pip install -r requirements.txt 1>&2 + exit /b 1 +) + +python "%SCRIPT_DIR%main.py" %* diff --git a/main.py b/main.py index ef17454..1fde348 100644 --- a/main.py +++ b/main.py @@ -4,18 +4,38 @@ import sys from gui.app import create_app, sanitize_qt_plugin_env +from gui.ffmpeg_env import ensure_ffmpeg_on_path, missing_ffmpeg_tools from gui.shortcuts import register_shortcuts def main() -> int: # Ensure Qt plugin env is sanitized before importing modules that may import cv2. sanitize_qt_plugin_env() + # Make a bundled ffmpeg (frozen builds) discoverable before any worker runs. + ensure_ffmpeg_on_path() from gui.main_window import MainWindow app = create_app() window = MainWindow() register_shortcuts(window) window.show() + + missing = missing_ffmpeg_tools() + if missing: + from PySide6.QtWidgets import QMessageBox + + tools = " and ".join(missing) + QMessageBox.warning( + window, + "FFmpeg not found", + f"Datamosh needs {tools} to import, preview, and render clips, but " + f"{'they were' if len(missing) > 1 else 'it was'} not found on your PATH.\n\n" + "Install FFmpeg and make sure it is on PATH:\n" + " • Windows: winget install Gyan.FFmpeg (then reopen the app)\n" + " • macOS: brew install ffmpeg\n" + " • Linux: sudo apt install ffmpeg\n\n" + "Imports and renders will fail until FFmpeg is available.", + ) return app.exec() diff --git a/mosh.py b/mosh.py index a80c042..4f9e0c7 100644 --- a/mosh.py +++ b/mosh.py @@ -16,13 +16,17 @@ from dataclasses import dataclass from pathlib import Path from collections import defaultdict -from typing import Dict, List, Optional, Sequence, Set +from typing import Callable, Dict, List, Optional, Sequence, Set class AviParseError(Exception): """Raised when the source AVI does not match expectations.""" +class MoshAborted(Exception): + """Raised cooperatively when a caller's should_abort() callback returns True.""" + + @dataclass class AviChunk: """Single chunk inside the LIST movi section.""" @@ -94,12 +98,13 @@ def pack_le_uint(buffer: bytearray, offset: int, value: int) -> None: struct.pack_into(" tuple[int, int, int, int]: +def locate_chunks(data: bytes) -> tuple[int, int, Optional[int], Optional[int]]: """ - Locate the LIST movi and idx1 chunks. + Locate the LIST movi and (optional) idx1 chunks. Returns: - (movi_pos, movi_size, idx1_pos, idx1_size) + (movi_pos, movi_size, idx1_pos, idx1_size) — idx1_pos/idx1_size are None + when the file has no legacy idx1 (e.g. OpenDML-indexed sources). """ pos = 12 # Skip RIFF header. movi_pos = movi_size = idx1_pos = idx1_size = None # type: ignore @@ -128,9 +133,8 @@ def locate_chunks(data: bytes) -> tuple[int, int, int, int]: if movi_pos is None: raise AviParseError("LIST movi chunk not found") - if idx1_pos is None: - raise AviParseError("idx1 chunk not found") - + # idx1 is optional: OpenDML files index via indx/ix## and some muxers omit it. + # When absent we parse the movi structurally and write a fresh idx1 on rebuild. return movi_pos, movi_size, idx1_pos, idx1_size @@ -145,8 +149,7 @@ def parse_idx1(data: bytes, idx1_pos: int, idx1_size: int) -> List[tuple[bytes, size = read_le_uint(data, pos + 12) entries.append((chunk_id, flags, offset, size)) pos += 16 - if pos != end: - raise AviParseError("idx1 chunk has trailing bytes") + # Any sub-16-byte tail is tolerated (some muxers pad idx1); we just stop. return entries @@ -181,21 +184,20 @@ def parse_movi_chunks( if chunk_data_end > payload_len: raise AviParseError("Chunk exceeds movi payload size") - if entry_idx >= len(idx_entries): - raise AviParseError("idx1 has fewer entries than movi chunks") - - idx_chunk_id, flags, offset, size_from_idx = idx_entries[entry_idx] - # Validate metadata matches the index. - if idx_chunk_id != chunk_id: - raise AviParseError("movi chunk order does not match idx1") - if size_from_idx != chunk_size: - raise AviParseError("Chunk size mismatch between movi and idx1") - # idx1 offsets are measured from the start of the LIST movi chunk header - # plus four bytes for the 'movi' tag. Given pos is measured from the start - # of the movi payload, the expected offset is pos + 4. - expected_offset = pos + 4 - if offset != expected_offset: - raise AviParseError("Chunk offset mismatch between movi and idx1") + + # idx1 is advisory and used ONLY for the per-chunk keyframe flag. Its + # offsets and sizes are ignored (both are rebuilt on write), and a + # missing / extra / reordered entry is tolerated rather than fatal: a chunk + # with no positional idx1 match simply defaults to a non-keyframe. This + # accepts spec-valid AVIs whose idx1 uses absolute (file-relative) offsets, + # or which omit/duplicate index entries — cases the old strict 1:1 lockstep + # walk rejected outright. + flags = 0 + if entry_idx < len(idx_entries): + idx_chunk_id, idx_flags, _idx_offset, _idx_size = idx_entries[entry_idx] + if idx_chunk_id == chunk_id: + flags = idx_flags + entry_idx += 1 chunk_data = movi_payload[chunk_data_start:chunk_data_end] stream_id = _parse_stream_id(chunk_id) @@ -215,14 +217,10 @@ def parse_movi_chunks( ) ) - entry_idx += 1 pos = chunk_data_end if chunk_size % 2 == 1: pos += 1 # Skip padding byte. - if entry_idx != len(idx_entries): - raise AviParseError("idx1 contains extra entries after parsing movi") - return chunks @@ -233,12 +231,22 @@ def parse_avi_file(path: Path, clip_id: int) -> AviStructure: movi_pos, movi_size, idx1_pos, idx1_size = locate_chunks(data) movi_payload = data[movi_pos + 12 : movi_pos + 8 + movi_size] - idx_entries = parse_idx1(data, idx1_pos, idx1_size) + if idx1_pos is not None: + idx_entries = parse_idx1(data, idx1_pos, idx1_size) + else: + idx_entries = [] chunks = parse_movi_chunks(movi_payload, idx_entries, clip_id=clip_id) prefix = bytearray(data[:movi_pos]) - between = data[movi_pos + 8 + movi_size : idx1_pos] - suffix = data[idx1_pos + 8 + idx1_size :] + movi_end = movi_pos + 8 + movi_size + if idx1_pos is not None: + between = data[movi_end:idx1_pos] + suffix = data[idx1_pos + 8 + idx1_size :] + else: + # No idx1 in the source: keep everything after movi as suffix; the rebuild + # inserts a fresh idx1 right after the movi section. + between = b"" + suffix = data[movi_end:] return AviStructure(prefix=prefix, between=between, suffix=suffix, chunks=chunks) @@ -351,6 +359,7 @@ def process_chunks( drop_key_indices: Optional[Set[int]] = None, clip_options: Optional[Dict[int, ClipOptions]] = None, drop_appended_first: bool = True, + should_abort: Optional[Callable[[], bool]] = None, ) -> List[AviChunk]: if duplicate_count < 0: raise ValueError("duplicate_count must be >= 0") @@ -364,6 +373,8 @@ def process_chunks( per_clip_p_counter = defaultdict(int) for chunk in chunks: + if should_abort is not None and should_abort(): + raise MoshAborted() if not chunk.is_video: processed.append(chunk.clone()) continue @@ -384,11 +395,16 @@ def process_chunks( if chunk.is_keyframe: clip_key_index = per_clip_key_index[clip_id] + # Keyframe keep/drop precedence, highest first: + # 1. explicit drop (drop_specific_keys / drop_key_indices) + # 2. explicit keep (keep_specific_keys / keep_key_indices) + # 3. implicit drop_first_keyframe (drops keyframe ordinal 0) + # 4. keep-initial limit (drops keyframes past the kept budget) + # So an explicitly kept ordinal 0 now survives drop_first, and an index + # listed in BOTH keep and drop is dropped (explicit drop wins). With no + # explicit sets, behavior is unchanged: drop_first + limit as before. keep = True - - if clip_drop_first and clip_key_index == 0: - keep = False - elif clip_drop_set is not None and clip_key_index in clip_drop_set: + if clip_drop_set is not None and clip_key_index in clip_drop_set: keep = False elif drop_key_indices is not None and global_key_index in drop_key_indices: keep = False @@ -396,6 +412,8 @@ def process_chunks( keep = True elif keep_key_indices is not None and global_key_index in keep_key_indices: keep = True + elif clip_drop_first and clip_key_index == 0: + keep = False elif per_clip_keys_kept[clip_id] >= clip_keep_limit: keep = False @@ -448,12 +466,22 @@ def build_movi_and_index( if chunk.is_video: video_frames += 1 + if 4 + len(movi_payload) > 0xFFFFFFFF: + raise AviParseError( + "Output exceeds the 4 GB AVI size limit. Reduce the duplicate count, " + "increase the duplicate gap, or shorten the clips." + ) movi_chunk = ( b"LIST" + struct.pack(" 0xFFFFFFFF: + raise AviParseError( + "Output index exceeds the 4 GB AVI size limit. Reduce the duplicate " + "count, increase the duplicate gap, or shorten the clips." + ) idx_chunk = b"idx1" + struct.pack(" Optional[int]: if scale_filter: cmd.extend(["-vf", scale_filter]) if keep_audio: - cmd.extend(["-c:a", "copy"]) + # The AVI container does not reliably carry AAC/Opus (the usual audio in + # MP4/WebM/MOV sources), so copying the stream yields silent or unplayable + # audio in the moshed output. Re-encode to MP3, which AVI handles cleanly. + cmd.extend(["-c:a", "libmp3lame", "-q:a", "4"]) else: cmd.append("-an") cmd.append(str(dst)) try: - subprocess.run(cmd, check=True) - except FileNotFoundError as exc: # pragma: no cover - CLI error path. - raise RuntimeError("ffmpeg binary not found on PATH") from exc - except subprocess.CalledProcessError as exc: # pragma: no cover - CLI error path. - raise RuntimeError(f"ffmpeg failed with exit code {exc.returncode}") from exc + result = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError as exc: + raise RuntimeError( + f"ffmpeg binary not found (looked for '{ffmpeg_bin}' on PATH). " + "Install ffmpeg and ensure it is on your PATH." + ) from exc + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + tail = "\n".join(stderr.splitlines()[-6:]) + lowered = stderr.lower() + if "unknown encoder" in lowered and "libxvid" in lowered: + raise RuntimeError( + "This ffmpeg build has no 'libxvid' encoder, which Datamosh requires. " + "Install a full ffmpeg build that includes libxvid (e.g. the GPL build)." + + (f"\n\n{tail}" if tail else "") + ) + message = f"ffmpeg failed (exit code {result.returncode})." + if tail: + message += f"\n\n{tail}" + raise RuntimeError(message) def rewrite_avi( @@ -549,6 +596,7 @@ def rewrite_avi( drop_key_indices: Optional[Set[int]] = None, clip_options: Optional[Dict[int, ClipOptions]] = None, drop_appended_first: bool = True, + should_abort: Optional[Callable[[], bool]] = None, ) -> None: base = parse_avi_file(source_path, clip_id=0) @@ -566,6 +614,7 @@ def rewrite_avi( drop_key_indices=drop_key_indices, clip_options=clip_options, drop_appended_first=drop_appended_first, + should_abort=should_abort, ) movi_chunk, idx_chunk, video_frames = build_movi_and_index(processed_chunks) @@ -579,6 +628,11 @@ def rewrite_avi( rebuilt += idx_chunk rebuilt += base.suffix + if len(rebuilt) - 8 > 0xFFFFFFFF: + raise AviParseError( + "Output exceeds the 4 GB AVI size limit. Reduce the duplicate count, " + "increase the duplicate gap, or shorten the clips." + ) pack_le_uint(rebuilt, 4, len(rebuilt) - 8) # Update RIFF size. output_path.write_bytes(bytes(rebuilt)) diff --git a/tests/test_clip_panel.py b/tests/test_clip_panel.py new file mode 100644 index 0000000..4643110 --- /dev/null +++ b/tests/test_clip_panel.py @@ -0,0 +1,32 @@ +"""Tests for ClipPanel ingest-cancellation (stale-callback invalidation).""" + +import pytest + +from gui.models.project import Project +from gui.widgets.clip_panel import ClipPanel + + +@pytest.fixture +def panel(qtbot): + widget = ClipPanel(Project()) + qtbot.addWidget(widget) + return widget + + +def test_cancel_ingest_bumps_epoch(panel): + epoch = panel._ingest_epoch + panel.cancel_ingest() + assert panel._ingest_epoch == epoch + 1 + + +def test_discard_orphan_removes_temp_dir(panel, tmp_path): + d = tmp_path / "datamosh-norm-x" + d.mkdir() + media = d / "n.avi" + media.write_bytes(b"x") + panel._discard_orphan(str(media)) + assert not d.exists() + + +def test_discard_orphan_is_safe_on_missing_path(panel, tmp_path): + panel._discard_orphan(str(tmp_path / "nope" / "n.avi")) # must not raise diff --git a/tests/test_clip_selection_regressions.py b/tests/test_clip_selection_regressions.py new file mode 100644 index 0000000..7d18a2d --- /dev/null +++ b/tests/test_clip_selection_regressions.py @@ -0,0 +1,190 @@ +"""Regression tests for the clip selection / reorder / temp-cleanup bugs. + +Covers three data-integrity bugs where editing operations silently bound the +settings panel to the wrong clip or destroyed media still reachable via undo: + +* Removing the selected (non-last) clip left the settings panel bound to the + deleted row (no clip_selected re-emit because the row index was unchanged). +* Clip-bin drag-reorder bypassed Project entirely (no undo, stale selection, + no clips_changed), so edits landed on the wrong clip and could not be undone. +* Removing a clip eagerly rmtree'd its normalized temp dir, so an undo restored + a clip reporting is_ready()==True whose media file was already gone. +""" + +import pytest +from pathlib import Path + +from PySide6.QtCore import QMimeData, QModelIndex, Qt + +from gui.models.clip_model import ClipProfile, ClipListModel +from gui.models.project import Project + + +@pytest.fixture +def project(qtbot): # qtbot ensures a QApplication exists + return Project() + + +def _clips(*names): + return [ClipProfile(source_path=Path(f"/tmp/{n}.mp4")) for n in names] + + +# -- Selection rebind on removal ------------------------------------------------ + +def test_remove_selected_nonlast_clip_rebinds_selection(project): + a, b, c = _clips("a", "b", "c") + for clip in (a, b, c): + project.add_clip(clip, add_to_timeline=False) + project.select_clip(1) + assert project.selected_clip is b + + seen: list[int] = [] + project.clip_selected.connect(seen.append) + project.remove_clip(1) + + assert len(project.clips) == 2 + # Row 1 is now the old clip c; selection must follow it, not the deleted b. + assert project.selected_clip is c + assert seen and seen[-1] == 1 # re-emitted despite the row index being unchanged + + +def test_remove_clip_before_selection_keeps_identity_and_resyncs(project): + a, b, c = _clips("a", "b", "c") + for clip in (a, b, c): + project.add_clip(clip, add_to_timeline=False) + project.select_clip(2) + assert project.selected_clip is c + + seen: list[int] = [] + project.clip_selected.connect(seen.append) + project.remove_clip(0) # removing an earlier row shifts the selection down + + assert len(project.clips) == 2 + assert project.selected_clip is c # same object, now at row 1 + assert project.selected_row == 1 + assert seen and seen[-1] == 1 # settings panel rebinds to the new row + + +def test_remove_midtimeline_clip_follows_selected_segment(project): + # timeline [a, x, b, c] with the b segment selected; removing the x segment + # (before it) must follow b to its new index, not leave selection pointing at c. + a, x, b, c = _clips("a", "x", "b", "c") + for clip in (a, x, b, c): + project.add_clip(clip) # add_to_timeline=True + project.select_timeline_item(2) + assert project.timeline_items[2].clip is b + + project.remove_clip(project.row_for_clip(x)) + + assert len(project.timeline_items) == 3 + assert project.selected_timeline_index == 1 + assert project.timeline_items[project.selected_timeline_index].clip is b + + +def test_remove_last_clip_clears_selection(project): + (a,) = _clips("a") + project.add_clip(a, add_to_timeline=False) + project.select_clip(0) + seen: list[int] = [] + project.clip_selected.connect(seen.append) + project.remove_clip(0) + assert project.selected_clip is None + assert project.selected_row == -1 + assert seen and seen[-1] == -1 + + +# -- Drag-reorder routed through Project --------------------------------------- + +def _drop(model: ClipListModel, source_row: int, drop_row: int) -> bool: + mime = QMimeData() + mime.setData(model.MIME_TYPE, str(source_row).encode()) + return model.dropMimeData(mime, Qt.DropAction.MoveAction, drop_row, 0, QModelIndex()) + + +def test_drag_reorder_is_undoable_and_follows_selection(project): + a, b, c = _clips("a", "b", "c") + for clip in (a, b, c): + project.add_clip(clip, add_to_timeline=False) + project.select_clip(1) # b + + changed: list[int] = [] + project.clips_changed.connect(lambda: changed.append(1)) + + assert _drop(project.clip_model, source_row=2, drop_row=0) is True + # c moved to the front: [c, a, b] + assert project.clips[0] is c and project.clips[1] is a and project.clips[2] is b + assert changed # clips_changed fired (preview refresh) + assert project.selected_clip is b # selection followed the moved clip + assert project.selected_row == 2 + + assert project.undo() is True # reorder is undoable + assert project.clips[0] is a and project.clips[1] is b and project.clips[2] is c + + +def test_dropmimedata_rejects_malformed_payloads(project): + model = project.clip_model + for clip in _clips("a", "b"): + project.add_clip(clip, add_to_timeline=False) + + wrong_action = QMimeData() + wrong_action.setData(model.MIME_TYPE, b"0") + assert model.dropMimeData(wrong_action, Qt.DropAction.CopyAction, 0, 0, QModelIndex()) is False + + empty = QMimeData() + empty.setData(model.MIME_TYPE, b"") + assert model.dropMimeData(empty, Qt.DropAction.MoveAction, 0, 0, QModelIndex()) is False + + garbage = QMimeData() + garbage.setData(model.MIME_TYPE, b"not-an-int") + assert model.dropMimeData(garbage, Qt.DropAction.MoveAction, 0, 0, QModelIndex()) is False + + +# -- Deferred temp-dir cleanup ------------------------------------------------- + +def _normalized_clip(tmp_path: Path, name: str) -> tuple[ClipProfile, Path]: + tdir = tmp_path / f"datamosh-norm-{name}" + tdir.mkdir() + media = tdir / f"{name}_normalized.avi" + media.write_bytes(b"FAKE-AVI") + clip = ClipProfile(source_path=Path(f"/tmp/{name}.mp4")) + clip.normalized_path = media + clip.temp_dir = tdir + return clip, media + + +def test_remove_then_undo_preserves_normalized_media(project, tmp_path): + clip, media = _normalized_clip(tmp_path, "x") + project.add_clip(clip, add_to_timeline=False) + assert clip.is_ready() + + project.remove_clip(0) + # The undo snapshot still references the clip, so its media must survive. + assert media.exists() + + assert project.undo() is True + assert clip in project.clips + assert clip.is_ready() + assert media.exists() # restored clip's media is intact, not a dangling path + + +def test_remove_without_undo_reaps_temp_dir(project, tmp_path): + clip, media = _normalized_clip(tmp_path, "y") + tdir = clip.temp_dir + project.add_clip(clip, record_undo=False, add_to_timeline=False) + assert tdir.exists() + + project.remove_clip(0, record_undo=False) + # No history references the clip, so its temp dir is reaped immediately. + assert not tdir.exists() + + +def test_cleanup_all_removes_every_temp_dir(project, tmp_path): + clip_a, _ = _normalized_clip(tmp_path, "a") + clip_b, _ = _normalized_clip(tmp_path, "b") + project.add_clip(clip_a, record_undo=False, add_to_timeline=False) + project.add_clip(clip_b, record_undo=False, add_to_timeline=False) + dirs = [clip_a.temp_dir, clip_b.temp_dir] + assert all(d.exists() for d in dirs) + + project.cleanup_all() + assert all(not d.exists() for d in dirs) diff --git a/tests/test_ffmpeg_env.py b/tests/test_ffmpeg_env.py new file mode 100644 index 0000000..a6340ee --- /dev/null +++ b/tests/test_ffmpeg_env.py @@ -0,0 +1,49 @@ +"""Tests for gui.ffmpeg_env (Windows ffmpeg discovery + console suppression).""" + +import os +import subprocess +import sys + +from gui import ffmpeg_env + + +def test_missing_ffmpeg_tools_reports_both_when_absent(monkeypatch): + monkeypatch.setattr(ffmpeg_env.shutil, "which", lambda name: None) + assert ffmpeg_env.missing_ffmpeg_tools() == ["ffmpeg", "ffprobe"] + + +def test_missing_ffmpeg_tools_empty_when_present(monkeypatch): + monkeypatch.setattr(ffmpeg_env.shutil, "which", lambda name: f"/usr/bin/{name}") + assert ffmpeg_env.missing_ffmpeg_tools() == [] + + +def test_missing_ffmpeg_tools_reports_only_absent(monkeypatch): + monkeypatch.setattr( + ffmpeg_env.shutil, "which", + lambda name: None if name == "ffprobe" else "/usr/bin/ffmpeg", + ) + assert ffmpeg_env.missing_ffmpeg_tools() == ["ffprobe"] + + +def test_ensure_ffmpeg_on_path_noop_when_not_frozen(monkeypatch): + monkeypatch.setattr(ffmpeg_env.sys, "frozen", False, raising=False) + before = os.environ.get("PATH") + ffmpeg_env.ensure_ffmpeg_on_path() + assert os.environ.get("PATH") == before + + +def test_suppress_subprocess_console_idempotent_and_platform_aware(): + original = subprocess.Popen + try: + ffmpeg_env.suppress_subprocess_console() + first = subprocess.Popen + ffmpeg_env.suppress_subprocess_console() + # Second call must not re-wrap (no nesting / runaway subclassing). + assert subprocess.Popen is first + if sys.platform == "win32": + assert first is not original + assert getattr(first, "_datamosh_no_window", False) is True + else: + assert first is original # no-op off Windows + finally: + subprocess.Popen = original diff --git a/tests/test_mosh_engine.py b/tests/test_mosh_engine.py new file mode 100644 index 0000000..651a397 --- /dev/null +++ b/tests/test_mosh_engine.py @@ -0,0 +1,214 @@ +"""Engine tests for the mosh.py robustness fixes (idx1 tolerance, precedence, ffmpeg).""" + +import struct +import pytest + +import mosh +from mosh import AVIIF_KEYFRAME + + +# -- Minimal in-memory AVI builder -------------------------------------------- + +# frames: list of (is_keyframe, data_bytes). idx_mode controls how idx1 is written. +FRAMES = [(True, b"AAAA"), (False, b"BBBBBB"), (False, b"CC"), (True, b"DDDD")] + + +def _build_avi(frames, idx_mode="relative"): + chunks = bytearray() + entries = [] # (chunk_id, flags, movi_relative_offset, size) + rel = 4 # first chunk offset within the movi payload (spec convention) + for is_kf, data in frames: + cid = b"00dc" + size = len(data) + entries.append((cid, AVIIF_KEYFRAME if is_kf else 0, rel, size)) + chunks += cid + struct.pack(" no keyframe info + + +def test_parse_short_idx1(tmp_path): + st = _parse(tmp_path, _build_avi(FRAMES, "short")) + assert len(st.chunks) == 4 # last chunk just lacks a flag; no crash + + +def test_parse_extra_idx1(tmp_path): + st = _parse(tmp_path, _build_avi(FRAMES, "extra")) + assert len(st.chunks) == 4 # surplus index entry ignored + + +def test_roundtrip_preserves_chunks_and_keyframes(tmp_path): + src = tmp_path / "in.avi" + src.write_bytes(_build_avi(FRAMES, "relative")) + out = tmp_path / "out.avi" + mosh.rewrite_avi(src, out, keep_initial_keyframes=99, duplicate_count=0, duplicate_gap=1) + st = mosh.parse_avi_file(out, clip_id=0) + assert len(st.chunks) == 4 + assert [c.is_keyframe for c in st.chunks] == [True, False, False, True] + + +# -- keep/drop precedence ----------------------------------------------------- + +def _kf(clip_id=0): + return mosh.AviChunk(b"00dc", AVIIF_KEYFRAME, b"x", True, True, 0, clip_id) + + +def test_explicit_keep_survives_drop_first(): + chunks = [_kf(), _kf()] # keyframe ordinals 0 and 1 + opts = {0: mosh.ClipOptions( + keep_initial_keyframes=0, duplicate_count=0, duplicate_gap=1, + drop_first_keyframe=True, keep_specific_keys={0}, + )} + out = mosh.process_chunks(chunks, 0, 0, 1, clip_options=opts) + # ordinal 0 explicitly kept despite drop_first; ordinal 1 dropped (limit 0). + assert len(out) == 1 + + +def test_explicit_drop_beats_explicit_keep(): + chunks = [_kf()] + opts = {0: mosh.ClipOptions( + keep_initial_keyframes=5, duplicate_count=0, duplicate_gap=1, + keep_specific_keys={0}, drop_specific_keys={0}, + )} + out = mosh.process_chunks(chunks, 0, 0, 1, clip_options=opts) + assert len(out) == 0 # explicit drop wins over explicit keep + + +def test_process_chunks_aborts_cooperatively(): + chunks = [_kf() for _ in range(10)] + with pytest.raises(mosh.MoshAborted): + mosh.process_chunks(chunks, 5, 0, 1, should_abort=lambda: True) + + +def test_process_chunks_runs_when_abort_false(): + chunks = [_kf(), _kf()] + out = mosh.process_chunks(chunks, 5, 0, 1, should_abort=lambda: False) + assert len(out) == 2 + + +def test_default_drop_first_unchanged_without_explicit_sets(): + chunks = [_kf(), _kf()] + opts = {0: mosh.ClipOptions( + keep_initial_keyframes=5, duplicate_count=0, duplicate_gap=1, + drop_first_keyframe=True, + )} + out = mosh.process_chunks(chunks, 0, 0, 1, clip_options=opts) + assert len(out) == 1 # ordinal 0 dropped by drop_first, ordinal 1 kept + + +# -- normalize_to_xvid error surfacing ---------------------------------------- + +class _Result: + def __init__(self, returncode, stderr=""): + self.returncode = returncode + self.stdout = "" + self.stderr = stderr + + +def test_normalize_surfaces_ffmpeg_stderr(tmp_path, monkeypatch): + monkeypatch.setattr( + mosh.subprocess, "run", + lambda *a, **k: _Result(1, "frame 1\nError: something broke\n"), + ) + with pytest.raises(RuntimeError) as ei: + mosh.normalize_to_xvid(tmp_path / "in.mp4", tmp_path / "out.avi") + assert "something broke" in str(ei.value) + + +def test_normalize_detects_missing_libxvid(tmp_path, monkeypatch): + monkeypatch.setattr( + mosh.subprocess, "run", + lambda *a, **k: _Result(1, "Unknown encoder 'libxvid'\n"), + ) + with pytest.raises(RuntimeError) as ei: + mosh.normalize_to_xvid(tmp_path / "in.mp4", tmp_path / "out.avi") + assert "libxvid" in str(ei.value).lower() + + +def test_normalize_reencodes_audio_to_mp3(tmp_path, monkeypatch): + captured = {} + + def fake_run(cmd, *a, **k): + captured["cmd"] = cmd + return _Result(0) + + monkeypatch.setattr(mosh.subprocess, "run", fake_run) + mosh.normalize_to_xvid(tmp_path / "in.mp4", tmp_path / "out.avi", keep_audio=True) + assert "libmp3lame" in captured["cmd"] + assert "copy" not in captured["cmd"] # AAC/Opus stream-copy into AVI gives no sound + + +def test_normalize_drops_audio_when_disabled(tmp_path, monkeypatch): + captured = {} + + def fake_run(cmd, *a, **k): + captured["cmd"] = cmd + return _Result(0) + + monkeypatch.setattr(mosh.subprocess, "run", fake_run) + mosh.normalize_to_xvid(tmp_path / "in.mp4", tmp_path / "out.avi", keep_audio=False) + assert "-an" in captured["cmd"] + + +def test_normalize_missing_binary(tmp_path, monkeypatch): + def boom(*a, **k): + raise FileNotFoundError() + monkeypatch.setattr(mosh.subprocess, "run", boom) + with pytest.raises(RuntimeError) as ei: + mosh.normalize_to_xvid(tmp_path / "in.mp4", tmp_path / "out.avi") + assert "not found" in str(ei.value).lower() diff --git a/tests/test_mosh_worker.py b/tests/test_mosh_worker.py index 5ad1401..19e5784 100644 --- a/tests/test_mosh_worker.py +++ b/tests/test_mosh_worker.py @@ -128,3 +128,98 @@ def test_mosh_worker_exception(qtbot, temp_dir): worker.start() assert "boom" in errors[0] + + +def test_mosh_worker_size_overflow_message(qtbot, temp_dir): + """A struct.error from the 32-bit AVI size fields becomes an actionable message.""" + import struct as _struct + + clip = ClipProfile( + source_path=Path("/tmp/a.mp4"), + normalized_path=temp_dir / "norm.avi", + ) + out = temp_dir / "out.avi" + overflow = _struct.error("'I' format requires 0 <= number <= 4294967295") + + with patch.object(mosh, "rewrite_avi", side_effect=overflow): + worker = MoshWorker([clip], out) + errors = [] + worker.error.connect(errors.append) + + with qtbot.waitSignal(worker.error, timeout=5000): + worker.start() + + assert errors + assert "4 GB" in errors[0] or "too large" in errors[0] + # The cryptic raw struct message must not leak to the user. + assert "format requires" not in errors[0] + + +def test_mosh_worker_transcodes_to_mp4(qtbot, temp_dir, monkeypatch): + """An .mp4 output moshes to a temp AVI, then transcodes via ffmpeg (libx264).""" + clip = ClipProfile(source_path=Path("/tmp/a.mp4"), normalized_path=temp_dir / "norm.avi") + out = temp_dir / "out.mp4" + calls = {} + + def fake_rewrite(src, dst, **kw): + calls["rewrite_dst"] = str(dst) + Path(dst).write_bytes(b"FAKE-AVI") + + def fake_run(cmd, *a, **k): + calls["ffmpeg_cmd"] = cmd + Path(cmd[-1]).write_bytes(b"MP4") # ffmpeg produces the output + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(mosh, "rewrite_avi", fake_rewrite) + monkeypatch.setattr("gui.workers.mosh_worker.subprocess.run", fake_run) + + worker = MoshWorker([clip], out) + results = [] + worker.finished_ok.connect(results.append) + with qtbot.waitSignal(worker.finished_ok, timeout=5000): + worker.start() + + assert results and results[0] == str(out) + assert "libx264" in calls["ffmpeg_cmd"] + assert calls["rewrite_dst"].endswith(".avi") # moshed to a temp AVI first + + +def test_mosh_worker_avi_does_not_transcode(qtbot, temp_dir, monkeypatch): + clip = ClipProfile(source_path=Path("/tmp/a.mp4"), normalized_path=temp_dir / "norm.avi") + out = temp_dir / "out.avi" + ran = {"ffmpeg": False} + + monkeypatch.setattr(mosh, "rewrite_avi", lambda src, dst, **k: Path(dst).write_bytes(b"AVI")) + monkeypatch.setattr( + "gui.workers.mosh_worker.subprocess.run", + lambda *a, **k: ran.__setitem__("ffmpeg", True), + ) + + worker = MoshWorker([clip], out) + with qtbot.waitSignal(worker.finished_ok, timeout=5000): + worker.start() + assert ran["ffmpeg"] is False # native AVI export skips transcode + + +def test_mosh_worker_non_overflow_struct_error_not_mislabeled(qtbot, temp_dir): + """A struct.error from corrupt/truncated parsing must NOT claim the 4 GB limit.""" + import struct as _struct + + clip = ClipProfile( + source_path=Path("/tmp/a.mp4"), + normalized_path=temp_dir / "norm.avi", + ) + out = temp_dir / "out.avi" + parse_err = _struct.error("unpack_from requires a buffer of at least 4 bytes") + + with patch.object(mosh, "rewrite_avi", side_effect=parse_err): + worker = MoshWorker([clip], out) + errors = [] + worker.error.connect(errors.append) + + with qtbot.waitSignal(worker.error, timeout=5000): + worker.start() + + assert errors + assert "4 GB" not in errors[0] and "too large" not in errors[0] + assert "video data" in errors[0] diff --git a/tests/test_preview_widget.py b/tests/test_preview_widget.py new file mode 100644 index 0000000..74211e8 --- /dev/null +++ b/tests/test_preview_widget.py @@ -0,0 +1,61 @@ +"""Tests for the live-preview generation-token guard (no stale frame mixing).""" + +import pytest +from PySide6.QtGui import QImage + +from gui.models.project import Project +from gui.widgets.preview_widget import PreviewWidget + + +@pytest.fixture +def preview(qtbot): + widget = PreviewWidget(Project()) + qtbot.addWidget(widget) + yield widget + widget.shutdown() + + +def _img() -> QImage: + return QImage(2, 2, QImage.Format.Format_RGB888) + + +def test_stale_frame_callbacks_are_ignored(preview): + preview._generation = 5 + preview._frames = [] + preview._on_frame_decoded(3, 0, _img()) # from a superseded generation + assert preview._frames == [] + preview._on_frame_decoded(5, 0, _img()) # current generation + assert len(preview._frames) == 1 + + +def test_stale_mosh_done_starts_no_extractor(preview): + preview._generation = 5 + preview._extractor = None + preview._on_mosh_done(3, "does-not-exist.avi") # stale -> must be a no-op + assert preview._extractor is None + + +def test_stale_extraction_done_does_not_touch_display(preview): + preview._generation = 5 + preview._frames = [_img()] + preview._current_frame = 0 + # Stale completion must not re-show frames or reset the scrubber. + preview._on_extraction_done(2, 1) # no exception, no-op + + +def test_cancel_workers_bumps_generation_and_clears_refs(preview): + gen = preview._generation + preview._cancel_workers() + assert preview._generation == gen + 1 + assert preview._mosh_worker is None + assert preview._extractor is None + + +def test_reset_clears_frames_and_invalidates_generation(preview): + preview._frames = [_img(), _img()] + preview._current_frame = 1 + gen = preview._generation + preview.reset() + assert preview._frames == [] + assert preview._current_frame == 0 + assert preview._generation == gen + 1 diff --git a/tests/test_project_io.py b/tests/test_project_io.py new file mode 100644 index 0000000..b9309dd --- /dev/null +++ b/tests/test_project_io.py @@ -0,0 +1,203 @@ +"""Tests for .dmosh project serialization and load/clear of project state.""" + +import json +import pytest +from pathlib import Path + +from gui.models.clip_model import ClipProfile +from gui.models.project import Project +from gui.models import project_io + + +@pytest.fixture +def project(qtbot): # qtbot ensures a QApplication exists + return Project() + + +def test_serialize_captures_clip_settings_and_timeline(project): + a = ClipProfile( + source_path=Path("/tmp/a.mp4"), + keep_first=2, duplicate_count=3, duplicate_gap=4, + drop_first_keyframe=True, keep_keys_spec="0,5", drop_keys_spec="3", + ) + b = ClipProfile(source_path=Path("/tmp/b.mp4")) + project.add_clip(a) # add_to_timeline=True -> a timeline item per clip + project.add_clip(b) + project.select_clip(1) + project.select_timeline_item(0) + + data = project_io.serialize(project, {"mode": "normalize"}, "1.1.5") + + assert data["format"] == project_io.PROJECT_FORMAT + assert data["version"] == project_io.PROJECT_VERSION + assert len(data["clips"]) == 2 + c0 = data["clips"][0] + assert c0["keep_first"] == 2 and c0["duplicate_count"] == 3 and c0["duplicate_gap"] == 4 + assert c0["drop_first_keyframe"] is True + assert c0["keep_keys_spec"] == "0,5" and c0["drop_keys_spec"] == "3" + assert len(data["timeline"]) == 2 + assert data["selected_row"] == 1 + assert data["selected_timeline_index"] == 0 + assert data["import_settings"]["mode"] == "normalize" + + +def test_install_loaded_state_rebuilds_and_resets_history(project): + a = ClipProfile( + source_path=Path("/tmp/a.mp4"), + keep_first=2, duplicate_count=3, duplicate_gap=4, + drop_first_keyframe=True, keep_keys_spec="0,5", drop_keys_spec="3", + ) + project.add_clip(a) + project.add_clip(ClipProfile(source_path=Path("/tmp/b.mp4"))) + project.select_clip(1) + data = project_io.serialize(project, {}, "1.1.5") + + p2 = Project() + clips = p2.install_loaded_state(data) + + assert len(clips) == 2 + assert clips[0].keep_first == 2 and clips[0].duplicate_count == 3 and clips[0].duplicate_gap == 4 + assert clips[0].drop_first_keyframe is True + assert clips[0].keep_keys_spec == "0,5" and clips[0].drop_keys_spec == "3" + assert len(p2.timeline_items) == 2 + assert p2.selected_row == 1 + # A fresh load is a clean slate: no undo/redo history. + assert not p2.can_undo() and not p2.can_redo() + + +def test_write_then_read_roundtrip(project, tmp_path): + project.add_clip(ClipProfile(source_path=Path("/tmp/a.mp4"), duplicate_count=5)) + path = tmp_path / "proj.dmosh" + project_io.write_project(path, project, {"preset": "fast"}, "1.1.5") + + data = project_io.read_project(path) + assert data["clips"][0]["duplicate_count"] == 5 + assert data["import_settings"]["preset"] == "fast" + + +def test_iframe_clip_roundtrips(project): + c = ClipProfile( + source_path=Path("/tmp/img.png"), + source_kind="iframe", fps=24.0, frame_width=640, frame_height=480, + ) + project.add_clip(c) + data = project_io.serialize(project, {}, "1.1.5") + assert data["clips"][0]["kind"] == "iframe" + assert data["clips"][0]["iframe_fps"] == 24.0 + assert data["clips"][0]["iframe_width"] == 640 + + p2 = Project() + clips = p2.install_loaded_state(data) + assert clips[0].source_kind == "iframe" + assert clips[0].fps == 24.0 + assert clips[0].frame_width == 640 + + +def test_clear_empties_project(project): + project.add_clip(ClipProfile(source_path=Path("/tmp/a.mp4"))) + assert project.has_clips() + project.clear() + assert not project.has_clips() + assert not project.has_timeline_items() + assert project.selected_row == -1 + assert not project.can_undo() + + +def test_open_over_session_reclaims_old_temp_dirs(project, tmp_path): + tdir = tmp_path / "datamosh-norm-old" + tdir.mkdir() + (tdir / "n.avi").write_bytes(b"x") + old = ClipProfile(source_path=Path("/tmp/old.mp4")) + old.normalized_path = tdir / "n.avi" + old.temp_dir = tdir + project.add_clip(old, record_undo=False, add_to_timeline=False) + assert tdir.exists() + + data = { + "format": project_io.PROJECT_FORMAT, "version": 1, + "clips": [{"kind": "clip", "source_path": "/tmp/new.mp4"}], + "timeline": [], "selected_row": -1, "selected_timeline_index": -1, + } + project.install_loaded_state(data) + + assert not tdir.exists() # outgoing session's temp dir reclaimed on load + assert len(project.clips) == 1 + assert str(project.clips[0].source_path).endswith("new.mp4") + + +def test_per_segment_override_applies_in_render(project): + a = ClipProfile(source_path=Path("/tmp/a.mp4"), duplicate_count=0) + project.add_clip(a) # add_to_timeline -> one timeline item + assert len(project.timeline_items) == 1 + assert project.update_timeline_item_settings( + 0, keep_first=2, duplicate_count=5, duplicate_gap=3, keep_keys_spec="", drop_keys_spec="" + ) + segs = project.timeline_render_clips() + assert segs[0].duplicate_count == 5 and segs[0].keep_first == 2 and segs[0].duplicate_gap == 3 + assert a.duplicate_count == 0 # the source clip itself is untouched + + +def test_per_segment_override_undoable(project): + a = ClipProfile(source_path=Path("/tmp/a.mp4")) + project.add_clip(a) + project.update_timeline_item_settings( + 0, keep_first=0, duplicate_count=5, duplicate_gap=1, keep_keys_spec="", drop_keys_spec="" + ) + assert project.timeline_items[0].duplicate_count_override == 5 + assert project.undo() + assert project.timeline_items[0].duplicate_count_override is None # back to inherit + + +def test_per_segment_overrides_roundtrip(project): + a = ClipProfile(source_path=Path("/tmp/a.mp4")) + project.add_clip(a) + project.update_timeline_item_settings( + 0, keep_first=1, duplicate_count=7, duplicate_gap=2, keep_keys_spec="0,3", drop_keys_spec="5" + ) + data = project_io.serialize(project, {}, "1.1.5") + assert data["timeline"][0]["duplicate_count_override"] == 7 + p2 = Project() + p2.install_loaded_state(data) + item = p2.timeline_items[0] + assert item.duplicate_count_override == 7 and item.keep_first_override == 1 + assert item.keep_keys_spec_override == "0,3" and item.drop_keys_spec_override == "5" + + +def test_read_rejects_non_project(tmp_path): + p = tmp_path / "x.dmosh" + p.write_text('{"hello": 1}', encoding="utf-8") + with pytest.raises(project_io.ProjectLoadError): + project_io.read_project(p) + + +def test_read_rejects_bad_json(tmp_path): + p = tmp_path / "x.dmosh" + p.write_text("{not valid json", encoding="utf-8") + with pytest.raises(project_io.ProjectLoadError): + project_io.read_project(p) + + +def test_read_rejects_newer_version(tmp_path): + p = tmp_path / "x.dmosh" + p.write_text( + json.dumps({"format": project_io.PROJECT_FORMAT, "version": 999, "clips": [], "timeline": []}), + encoding="utf-8", + ) + with pytest.raises(project_io.ProjectLoadError): + project_io.read_project(p) + + +def test_install_skips_out_of_range_timeline_index(project): + # A timeline entry pointing past the clip list must be dropped, not crash. + data = { + "format": project_io.PROJECT_FORMAT, "version": 1, + "clips": [{"kind": "clip", "source_path": "/tmp/a.mp4"}], + "timeline": [{"clip_index": 0, "in_frame": 0, "out_frame": 0, + "drop_first_keyframe_override": None}, + {"clip_index": 7, "in_frame": 0, "out_frame": 0, + "drop_first_keyframe_override": None}], + "selected_row": 0, "selected_timeline_index": 0, + } + clips = project.install_loaded_state(data) + assert len(clips) == 1 + assert len(project.timeline_items) == 1 # the bogus index-7 entry is dropped diff --git a/tests/test_timeline_cut_mapping.py b/tests/test_timeline_cut_mapping.py new file mode 100644 index 0000000..bf9cf3b --- /dev/null +++ b/tests/test_timeline_cut_mapping.py @@ -0,0 +1,107 @@ +"""Regression test: cut/inject at playhead must use a SOURCE frame index. + +ClipRegion.duration_sec reflects the predicted OUTPUT length (source frames plus +duplicates). current_playhead_location() previously derived the cut frame from +fps * local_sec, i.e. an OUTPUT index, which project.split_timeline_item then +interpreted against the SOURCE frame count. With duplication active the output +count is inflated, so dropping the playhead at the visual midpoint produced a cut +clamped to the END of the source instead of its middle. +""" + +import pytest + +from gui.widgets.timeline_widget import TimelineCanvas, ClipRegion + + +@pytest.fixture +def canvas(qtbot): + c = TimelineCanvas() + qtbot.addWidget(c) + return c + + +def _region(source_frames: int, output_frames: int, fps: float = 25.0) -> ClipRegion: + return ClipRegion( + timeline_index=0, + clip_row=0, + label="clip", + frame_count=output_frames, + source_frame_count=source_frames, + fps=fps, + duration_sec=output_frames / fps, + loading=False, + ) + + +def test_playhead_midpoint_maps_to_source_midpoint_with_duplication(canvas): + # 100 source frames doubled to 200 output frames via duplication. + region = _region(source_frames=100, output_frames=200) + canvas._regions = [region] + canvas._total_duration = region.duration_sec + canvas._playhead_sec = region.duration_sec / 2.0 # visual midpoint + + idx, local_frame = canvas.current_playhead_location() + + assert idx == 0 + # Must be ~the SOURCE midpoint (50), not the source end (99, the old bug) and + # not the output midpoint (100). + assert 45 <= local_frame <= 55 + assert local_frame < region.source_frame_count # always a valid source index + + +def test_playhead_start_maps_to_frame_zero(canvas): + region = _region(source_frames=80, output_frames=160) + canvas._regions = [region] + canvas._total_duration = region.duration_sec + canvas._playhead_sec = 0.0 + + idx, local_frame = canvas.current_playhead_location() + assert idx == 0 + assert local_frame == 0 + + +def test_refresh_region_uses_segment_override(qtbot): + # The timeline region (and thus its viz/duration) must reflect the per-segment + # glitch override, not the source clip's value. + from pathlib import Path + from gui.widgets.timeline_widget import TimelineWidget + from gui.models.project import Project + from gui.models.clip_model import ClipProfile + + p = Project() + tw = TimelineWidget(p) + qtbot.addWidget(tw) + clip = ClipProfile( + source_path=Path("/tmp/a.mp4"), duplicate_count=0, + total_frames=100, normalized_path=Path("/tmp/n.avi"), + ) + p.add_clip(clip) # creates a timeline item + p.update_timeline_item_settings( + 0, keep_first=0, duplicate_count=4, duplicate_gap=1, keep_keys_spec="", drop_keys_spec="" + ) + tw.refresh() + region = tw._canvas._regions[0] + assert region.dup_count == 4 # segment override wins over the clip's 0 + + +def test_playhead_snaps_to_frame(canvas): + region = _region(source_frames=100, output_frames=100, fps=25.0) + canvas._regions = [region] + canvas._total_duration = region.duration_sec + frame_dur = region.duration_sec / 100 + raw = 10 * frame_dur + frame_dur * 0.7 # 70% into frame 10 + snapped = canvas._snap_sec_to_frame(raw) + # Snaps to ~1/3 into frame 10 (clearly inside the frame, left of centre). + assert abs(snapped - (10 + 0.3) * frame_dur) < 1e-6 + + +def test_playhead_without_duplication_still_maps_correctly(canvas): + # No duplication: output == source, midpoint should be ~half. + region = _region(source_frames=100, output_frames=100) + canvas._regions = [region] + canvas._total_duration = region.duration_sec + canvas._playhead_sec = region.duration_sec / 2.0 + + idx, local_frame = canvas.current_playhead_location() + assert idx == 0 + assert 45 <= local_frame <= 55 diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py new file mode 100644 index 0000000..67cb685 --- /dev/null +++ b/tests/test_toolbar.py @@ -0,0 +1,26 @@ +"""Tests for toolbar tooltip shortcut rendering (macOS shows native modifiers).""" + +import pytest +from PySide6.QtGui import QAction, QKeySequence + +from gui.widgets.toolbar import _tip + + +@pytest.fixture(autouse=True) +def _app(qtbot): + # qtbot ensures a QApplication for QAction/QKeySequence. + return qtbot + + +def test_tip_appends_native_shortcut(): + action = QAction("x") + action.setShortcut(QKeySequence("Ctrl+K")) + tip = _tip("Cut", action) + assert tip.startswith("Cut (") and tip.endswith(")") + # Whatever the platform native form is, the literal portable "Ctrl+K" appears + # on Win/Linux; on macOS it renders with the ⌘ glyph. Either way it's appended. + + +def test_tip_omits_parens_without_shortcut(): + action = QAction("y") # no shortcut + assert _tip("Check for app updates", action) == "Check for app updates"