Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ on:
- main
paths:
- docs/**
- VERSION
- .github/workflows/pages.yml
release:
types: [published]
workflow_dispatch:

permissions:
contents: write
Expand All @@ -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#(<div class=\"hero-label\">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:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,31 @@ 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: |
pyinstaller --noconfirm --clean --windowed --name Datamosh `
--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
Expand Down
46 changes: 0 additions & 46 deletions .github/workflows/update_beta5_release.yml

This file was deleted.

18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
14 changes: 7 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -659,25 +659,25 @@ <h2 class="section-title">Get Datamosh GUI</h2>
<div class="os">Linux</div>
<div class="arch">x86_64</div>
<div class="dl-links">
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh.GUI-1.1.5-x86_64.AppImage">AppImage <span class="ext">.appimage</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/datamosh-gui_1.1.5_amd64.deb">Debian Package <span class="ext">.deb</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/datamosh-gui-1.1.5-linux.tar.gz">Archive <span class="ext">.tar.gz</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-linux-x86_64.AppImage">AppImage <span class="ext">.appimage</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-linux-installer.deb">Debian Package <span class="ext">.deb</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-linux-portable.tar.gz">Archive <span class="ext">.tar.gz</span></a>
</div>
</div>
<div class="dl-card">
<div class="os">Windows</div>
<div class="arch">x86_64</div>
<div class="dl-links">
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh.GUI-1.1.5-setup.exe">Installer <span class="ext">.exe</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/datamosh-gui-1.1.5-windows.zip">Portable <span class="ext">.zip</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-windows-installer.exe">Installer <span class="ext">.exe</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-windows-portable.zip">Portable <span class="ext">.zip</span></a>
</div>
</div>
<div class="dl-card">
<div class="os">macOS</div>
<div class="arch">universal</div>
<div class="dl-links">
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh.GUI-1.1.5.dmg">Disk Image <span class="ext">.dmg</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/datamosh-gui-1.1.5-macos.zip">Archive <span class="ext">.zip</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-macos-installer.dmg">Disk Image <span class="ext">.dmg</span></a>
<a href="https://github.com/willbearfruits/datamosh-gui/releases/latest/download/Datamosh-1.1.5-macos-portable.zip">Archive <span class="ext">.zip</span></a>
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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())
Expand Down
29 changes: 26 additions & 3 deletions gui/dialogs/render_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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)
Expand Down
Loading
Loading