Compare commits
130 Commits
191d1cf299
..
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dd2d00a0 | |||
| 34d8ad1dc7 | |||
| 46bd617f0a | |||
| e8ecfc0525 | |||
| 198ec68382 | |||
| 920f724dbd | |||
| 94ea4c63ca | |||
| 653e4a5e13 | |||
| cd50b3ae0c | |||
| 10b77e79f7 | |||
| 5b4e4bf818 | |||
| bd4e97c45a | |||
| 1aeaad7f6d | |||
| 874632dffa | |||
| 86055f2072 | |||
| 5fddb06354 | |||
| e60263548d | |||
| 86f447f3d6 | |||
| 1d5b8023a2 | |||
| cb4392125d | |||
| 328c800d60 | |||
| 7a35e8268b | |||
| 465894af51 | |||
| 1004bd0a28 | |||
| 279aee14cb | |||
| 4f15f77175 | |||
| 17e42c44b3 | |||
| 8e8c8b9774 | |||
| b9e9fa927e | |||
| 5916b498b1 | |||
| bda423e8b5 | |||
| 7b569dd248 | |||
| 3a2fd3d50b | |||
| cfc0cb2f09 | |||
| d87b3c6da5 | |||
| 39e7b19bc5 | |||
| ab5c8ae3db | |||
| dcd4a6aace | |||
| 96d4dd8d89 | |||
| a6b91d9d3f | |||
| 12b06e8144 | |||
| 4c3b3fb2db | |||
| 89d6feee47 | |||
| 7051cc5b93 | |||
| 7c776e24af | |||
| 04e78eb355 | |||
| 97986d5138 | |||
| 3c903c7188 | |||
| bef08be091 | |||
| ccc94ccb5c | |||
| d031d6c285 | |||
| 9696b94b0c | |||
| 633e421a68 | |||
| a543c72ff5 | |||
| 31772b898c | |||
| 9b8d742fde | |||
| 8392c022f6 | |||
| d4357f0da4 | |||
| 2dcf9bc856 | |||
| 703874721b | |||
| bd37938a4a | |||
| e28af38743 | |||
| 039d383cf6 | |||
| e283d96417 | |||
| 1e99035393 | |||
| 0e38c5666e | |||
| f9c5a42453 | |||
| f8b148f77d | |||
| 462af36bce | |||
| ae15f9ef32 | |||
| 2ef387d87b | |||
| bcdda9c783 | |||
| e2b4f9bf8d | |||
| bb6e3c623a | |||
| 89e0478777 | |||
| c287788d9e | |||
| 5a5961ae21 | |||
| c174d891fb | |||
| 74e8656335 | |||
| 206b95fc28 | |||
| f11b3e298e | |||
| 22e2ad27a0 | |||
| fee907f26f | |||
| 122f89547b | |||
| b6e7b660a8 | |||
| 2304286147 | |||
| abe9e6ee66 | |||
| 01961e9192 | |||
| d3e48f5276 | |||
| 0996670020 | |||
| ffb99d3e66 | |||
| 439bc85505 | |||
| 13973dd53d | |||
| 9e07910df1 | |||
| 25250d6d8d | |||
| 93cee40b06 | |||
| 47ea6199fa | |||
| 8bc42cabd9 | |||
| 8148971aae | |||
| a8d8bd7fdc | |||
| f0484fac86 | |||
| fd25e8fa37 | |||
| c3c480acc7 | |||
| cf62940b84 | |||
| fc9287a5a6 | |||
| 1256819dde | |||
| 58043dd7e3 | |||
| a652872d15 | |||
| 9d7791557b | |||
| 2352d530d0 | |||
| 6e02a08046 | |||
| ff0cd00309 | |||
| 6573fa6e05 | |||
| 7931a0e3db | |||
| 939199717f | |||
| 8863634bce | |||
| e5f2c732d9 | |||
| 1e0c1ae892 | |||
| 2c95c2618a | |||
| c130010e36 | |||
| 02c63bdfde | |||
| f30955b63c | |||
| c80ebf9383 | |||
| 3d92a66660 | |||
| bdfb9354ca | |||
| 41c36267e9 | |||
| d720fc0541 | |||
| 31f4df4c5b | |||
| 39bbfedb9d | |||
| 025d5afe1f |
@@ -0,0 +1,115 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── Windows ────────────────────────────────────────────────
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyinstaller PyQt6 python-mpv
|
||||
|
||||
- name: Fetch ffmpeg
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ffUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
Invoke-WebRequest $ffUrl -OutFile ffmpeg.zip
|
||||
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg-tmp
|
||||
$bin = Get-ChildItem -Path ffmpeg-tmp -Recurse -Filter ffmpeg.exe | Select-Object -First 1
|
||||
Copy-Item "$($bin.DirectoryName)\ffmpeg.exe" .
|
||||
Copy-Item "$($bin.DirectoryName)\ffprobe.exe" .
|
||||
|
||||
- name: Fetch libmpv
|
||||
shell: pwsh
|
||||
run: |
|
||||
$release = Invoke-RestMethod "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
|
||||
$asset = $release.assets | Where-Object { $_.name -like "mpv-dev-x86_64-v3-*" } | Select-Object -First 1
|
||||
Invoke-WebRequest $asset.browser_download_url -OutFile mpv-dev.7z
|
||||
7z x mpv-dev.7z -ompv-dev
|
||||
Copy-Item mpv-dev\libmpv-2.dll .
|
||||
|
||||
- name: Build with PyInstaller
|
||||
run: pyinstaller 8cut.spec
|
||||
|
||||
- name: Package
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path dist\8cut\* -DestinationPath 8cut-windows.zip
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: 8cut-windows
|
||||
path: 8cut-windows.zip
|
||||
|
||||
# ── macOS (Apple Silicon) ──────────────────────────────────
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyinstaller PyQt6 python-mpv
|
||||
|
||||
- name: Install native deps
|
||||
run: |
|
||||
brew install mpv ffmpeg
|
||||
cp "$(brew --prefix mpv)/lib/libmpv.2.dylib" .
|
||||
cp "$(brew --prefix ffmpeg)/bin/ffmpeg" .
|
||||
cp "$(brew --prefix ffmpeg)/bin/ffprobe" .
|
||||
|
||||
- name: Build with PyInstaller
|
||||
run: pyinstaller 8cut.spec
|
||||
|
||||
- name: Fix dylib rpaths
|
||||
run: |
|
||||
DYLIB="dist/8cut/libmpv.2.dylib"
|
||||
if [ -f "$DYLIB" ]; then
|
||||
install_name_tool -id @executable_path/libmpv.2.dylib "$DYLIB"
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
cd dist
|
||||
zip -r ../8cut-macos-arm64.zip 8cut.app
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: 8cut-macos-arm64
|
||||
path: 8cut-macos-arm64.zip
|
||||
|
||||
# ── Create GitHub Release ──────────────────────────────────
|
||||
release:
|
||||
needs: [windows, macos]
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') && (needs.windows.result == 'success' || needs.macos.result == 'success') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
files: artifacts/**/*.zip
|
||||
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec for 8-cut.
|
||||
|
||||
Usage:
|
||||
pyinstaller 8cut.spec
|
||||
|
||||
Platform-specific notes:
|
||||
Windows: place libmpv-2.dll, ffmpeg.exe, ffprobe.exe next to main.py
|
||||
before building, or set FFMPEG_DIR / MPV_DIR env vars.
|
||||
macOS: place libmpv.2.dylib, ffmpeg, ffprobe next to main.py
|
||||
before building, or set FFMPEG_DIR / MPV_DIR env vars.
|
||||
Linux: system libmpv and ffmpeg are used from PATH (not bundled).
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
block_cipher = None
|
||||
system = platform.system()
|
||||
|
||||
# ---------- paths ----------------------------------------------------------
|
||||
|
||||
base = Path(SPECPATH)
|
||||
ffmpeg_dir = Path(os.environ.get("FFMPEG_DIR", base))
|
||||
mpv_dir = Path(os.environ.get("MPV_DIR", base))
|
||||
|
||||
# ---------- data files -----------------------------------------------------
|
||||
|
||||
datas = []
|
||||
|
||||
# YOLOv8 model (optional — large, skip if missing)
|
||||
yolo = base / "yolov8n.pt"
|
||||
if yolo.exists():
|
||||
datas.append((str(yolo), "."))
|
||||
|
||||
# ---------- native binaries ------------------------------------------------
|
||||
|
||||
binaries = []
|
||||
|
||||
if system == "Windows":
|
||||
for name in ("libmpv-2.dll",):
|
||||
p = mpv_dir / name
|
||||
if p.exists():
|
||||
binaries.append((str(p), "."))
|
||||
for name in ("ffmpeg.exe", "ffprobe.exe"):
|
||||
p = ffmpeg_dir / name
|
||||
if p.exists():
|
||||
binaries.append((str(p), "."))
|
||||
|
||||
elif system == "Darwin":
|
||||
for name in ("libmpv.2.dylib", "libmpv.dylib"):
|
||||
p = mpv_dir / name
|
||||
if p.exists():
|
||||
binaries.append((str(p), "."))
|
||||
break
|
||||
for name in ("ffmpeg", "ffprobe"):
|
||||
p = ffmpeg_dir / name
|
||||
if p.exists():
|
||||
binaries.append((str(p), "."))
|
||||
|
||||
# ---------- analysis -------------------------------------------------------
|
||||
|
||||
a = Analysis(
|
||||
[str(base / "main.py")],
|
||||
pathex=[str(base)],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=[
|
||||
"mpv",
|
||||
"PyQt6.QtOpenGL",
|
||||
"PyQt6.QtOpenGLWidgets",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# ultralytics is optional and huge — exclude from frozen build
|
||||
"ultralytics",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"onnxruntime",
|
||||
"opencv-python",
|
||||
# test / dev
|
||||
"pytest",
|
||||
"hypothesis",
|
||||
],
|
||||
noarchive=True,
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, cipher=block_cipher)
|
||||
|
||||
# ---------- executable -----------------------------------------------------
|
||||
|
||||
exe_kwargs = dict(
|
||||
pyz=pyz,
|
||||
a=a,
|
||||
name="8cut",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True, # temporary: show errors on launch
|
||||
)
|
||||
|
||||
if system == "Darwin":
|
||||
exe_kwargs["icon"] = str(base / "assets" / "logo.png")
|
||||
elif system == "Windows":
|
||||
ico = base / "assets" / "logo.ico"
|
||||
if ico.exists():
|
||||
exe_kwargs["icon"] = str(ico)
|
||||
|
||||
exe = EXE(**exe_kwargs)
|
||||
|
||||
# ---------- collect --------------------------------------------------------
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name="8cut",
|
||||
)
|
||||
|
||||
# ---------- macOS .app bundle (only on Darwin) -----------------------------
|
||||
|
||||
if system == "Darwin":
|
||||
app = BUNDLE(
|
||||
coll,
|
||||
name="8cut.app",
|
||||
icon=str(base / "assets" / "logo.png"),
|
||||
bundle_identifier="com.8cut.app",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "8cut",
|
||||
"CFBundleShortVersionString": "1.0.0",
|
||||
"NSHighResolutionCapable": True,
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
For the precise terms and conditions for copying, distribution and
|
||||
modification, see https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
@@ -1,28 +1,63 @@
|
||||
# 8-cut
|
||||
|
||||
A desktop tool for cutting 8-second clips from video files, designed for building [SELVA](https://github.com/google-deepmind/selva) datasets.
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="8-cut — 8-second clips for foley datasets" width="720">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ethanfel/8-cut/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"></a>
|
||||
</p>
|
||||
|
||||
A desktop tool for cutting 8-second clips from video files, designed for building foley datasets.
|
||||
|
||||
## Overview
|
||||
|
||||
8-cut lets you scrub through a video, mark a cut point, and export a fixed 8-second clip with one keypress. It tracks every export in a local SQLite database so you can resume a session or switch between resolution variants of the same source without duplicating work.
|
||||
8-cut lets you scrub through a video, mark a cut point, and export a batch of overlapping 8-second clips with one keypress. It tracks every export in a local SQLite database so you can resume a session without duplicating work.
|
||||
|
||||
All clips are exactly 8 seconds — this is a hard constraint of the SELVA format.
|
||||
All clips are exactly 8 seconds — the standard length for foley sound datasets.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/segment-diagram.svg" alt="Batch export diagram — 3 overlapping 8-second clips offset by a 3-second spread" width="720">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/K/L for frame-by-frame stepping
|
||||
- **Keyboard shortcuts** — J/L step one frame, Shift+J/L step one second, Space/P play/pause, K pause and return to cursor, E export, M jump to next marker
|
||||
- **Two export formats** — H.264/AAC MP4 or lossless WebP image sequence (frames + `.wav` audio extracted alongside)
|
||||
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; adjustable horizontal crop position
|
||||
- **Resize** — scale short side to a fixed pixel size (e.g. 256)
|
||||
- **Export history** — timeline markers show previously exported clips; fuzzy filename matching detects resolution variants of the same file (e.g. `_2160p` vs `_1080p`)
|
||||
- **Mask generation** — generate binary foreground masks per-frame using SAM2 (segmentation) or Depth Anything V2 (depth-based), via a bundled venv
|
||||
- **Playlist** — drag-and-drop multiple files; duplicates are ignored
|
||||
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
|
||||
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
|
||||
- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`)
|
||||
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
|
||||
- **Random portrait** — optionally apply a random portrait crop to a subset of each batch
|
||||
- **Resize** — scale short side to a fixed pixel size (e.g. 512)
|
||||
- **Sound annotation** — label and category fields saved to the clip database; label also written to `dataset.json`
|
||||
- **Export history** — timeline markers show previously exported clips; double-click to enter overwrite mode; right-click to delete
|
||||
- **End-frame preview** — floating window shows the last frame of the selection region
|
||||
- **Playlist** — drag-and-drop or use the Open Files button; right-click to remove items
|
||||
- **Playback loop** — plays the exact selection region on loop so you can preview what will be exported
|
||||
- **Group operations** — delete or overwrite acts on all sub-clips in a batch, not just one
|
||||
- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait") for the same video
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Left` / `J` | Step back 1 frame |
|
||||
| `Right` / `L` | Step forward 1 frame |
|
||||
| `Shift+Left` / `Shift+J` | Step back 1 second |
|
||||
| `Shift+Right` / `Shift+L` | Step forward 1 second |
|
||||
| `Space` / `P` | Toggle play/pause |
|
||||
| `K` | Pause and snap to cursor |
|
||||
| `E` | Export |
|
||||
| `M` | Jump to next marker (wraps) |
|
||||
| `N` | Next file in playlist |
|
||||
| `G` | Toggle cursor lock |
|
||||
| `?` / `F1` | Show keyboard shortcuts |
|
||||
|
||||
Shortcuts are suppressed when a text field has focus.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- `ffmpeg` in `PATH`
|
||||
- `ffmpeg` on `PATH`
|
||||
- PyQt6
|
||||
- python-mpv (requires libmpv)
|
||||
|
||||
@@ -30,15 +65,15 @@ All clips are exactly 8 seconds — this is a hard constraint of the SELVA forma
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
For mask generation tools, additional dependencies (PyTorch, transformers, segment-anything-2, opencv) are installed into `~/.8cut/venv/` via the Settings dialog.
|
||||
|
||||
### Platform notes
|
||||
|
||||
**Linux** — install libmpv via your package manager (`apt install libmpv-dev` / `pacman -S mpv`).
|
||||
| Platform | libmpv |
|
||||
|----------|--------|
|
||||
| **Linux** | `apt install libmpv-dev` or `pacman -S mpv` |
|
||||
| **macOS** | `brew install mpv` |
|
||||
| **Windows** | Download `mpv-2.dll` from [mpv Windows builds](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/) and place it in `PATH` or next to `main.py` |
|
||||
|
||||
**macOS** — install libmpv via Homebrew: `brew install mpv`.
|
||||
|
||||
**Windows** — `python-mpv` requires `mpv-2.dll` in `PATH` or in the same directory as `main.py`. Download it from the [mpv Windows builds](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/) page (pick the latest `mpv-dev-x86_64-*.7z`, extract `mpv-2.dll`). Also ensure `ffmpeg.exe` is in `PATH` (e.g. via [winget](https://winget.run/): `winget install ffmpeg`).
|
||||
Windows also needs `ffmpeg.exe` on `PATH` (e.g. `winget install ffmpeg`).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -46,47 +81,52 @@ For mask generation tools, additional dependencies (PyTorch, transformers, segme
|
||||
python main.py
|
||||
```
|
||||
|
||||
Drop a video onto the playlist or use the file picker. Scrub to your cut point, set the output folder and clip name, then press **Export** (or `E`).
|
||||
Drop videos onto the queue or click **+ Open Files**. Scrub to your cut point, then press **Export** (or `E`).
|
||||
|
||||
### Export formats
|
||||
### Export layout
|
||||
|
||||
| Format | Output |
|
||||
|--------|--------|
|
||||
| MP4 | `<folder>/<name>_NNN.mp4` — H.264 video + AAC audio |
|
||||
| WebP sequence | `<folder>/<name>_NNN/frame_%04d.webp` — lossless WebP frames + `<name>_NNN.wav` PCM audio |
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` / `J` | Step back 1 frame |
|
||||
| `→` / `L` | Step forward 1 frame |
|
||||
| `Shift+←` / `Shift+J` | Step back 1 second |
|
||||
| `Shift+→` / `Shift+L` | Step forward 1 second |
|
||||
| `Space` / `P` | Toggle play/pause |
|
||||
| `K` | Pause and snap video to cursor |
|
||||
| `E` | Export clip |
|
||||
| `M` | Jump to next export marker (wraps) |
|
||||
|
||||
Arrow keys and J/K/L are ignored when a text field has focus.
|
||||
|
||||
### Mask generation tools
|
||||
|
||||
Two standalone scripts live in `tools/`. They are run by the app via a managed venv but can also be called directly:
|
||||
Each export creates a group subfolder containing the overlapping sub-clips:
|
||||
|
||||
```
|
||||
python tools/sam_masks.py --input clip.mp4 --output masks_dir/
|
||||
python tools/depth_masks.py --input clip.mp4 --output masks_dir/
|
||||
output/
|
||||
clip_001/
|
||||
clip_001_0.mp4 # starts at cursor
|
||||
clip_001_1.mp4 # starts at cursor + spread
|
||||
clip_001_2.mp4 # starts at cursor + 2 * spread
|
||||
clip_002/
|
||||
...
|
||||
```
|
||||
|
||||
Both output one binary PNG per frame (`frame_0000.png`, …) where white = foreground.
|
||||
With WebP sequence format, each sub-clip becomes a directory of frames plus a `.wav`:
|
||||
|
||||
- **SAM2** (`sam_masks.py`) — uses `facebook/sam2-hiera-large`; center-point prompt propagated across all frames
|
||||
- **Depth Anything V2** (`depth_masks.py`) — uses `depth-anything/Depth-Anything-V2-Large-hf`; Otsu threshold on the depth map
|
||||
```
|
||||
output/
|
||||
clip_001/
|
||||
clip_001_0/
|
||||
frame_0001.webp
|
||||
frame_0002.webp
|
||||
...
|
||||
clip_001_0.wav
|
||||
```
|
||||
|
||||
### Sound annotation
|
||||
|
||||
Set a **Label** (e.g. "dog barking") and **Category** (Human / Animal / Vehicle / Tool / Music / Nature / Sport / Other) before exporting. These are saved to:
|
||||
|
||||
- `dataset.json` in the export folder — one entry per clip with `path` and `label`
|
||||
- The SQLite database — label and category, for recall when you revisit a marker
|
||||
|
||||
Labels persist between exports so you can cut many clips of the same class without retyping.
|
||||
|
||||
### Overwrite and delete
|
||||
|
||||
- **Double-click** a timeline marker to enter overwrite mode — the next export re-encodes all clips in that group to their original paths
|
||||
- **Right-click** a marker to delete it from the database
|
||||
- The **Delete** button removes all clips in a group from disk, database, and `dataset.json`
|
||||
|
||||
## Database
|
||||
|
||||
Export history is stored in `~/.8cut.db` (SQLite). The database records filename, start time, and output path for every clip. When you open a file, 8-cut checks whether a similar filename has been processed before (stripping resolution tags like `_2160p`, `_1080p`, codec tags, etc.) and pre-populates the timeline with existing markers.
|
||||
Export history is stored in `~/.8cut.db` (SQLite). The database records filename, start time, output path, label, category, and all encoding settings for every clip. When you open a file, 8-cut matches the filename and pre-populates the timeline with existing markers.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -94,4 +134,8 @@ Export history is stored in `~/.8cut.db` (SQLite). The database records filename
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
38 unit tests covering path builders, ffmpeg command generation, time formatting, and the processed-clips database.
|
||||
46 unit tests covering path builders, ffmpeg command generation, time formatting, database operations, group queries, profile isolation, and annotation handling.
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0](https://github.com/ethanfel/8-cut/blob/master/LICENSE)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,95 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 200">
|
||||
<defs>
|
||||
<linearGradient id="timeline-bg" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e1e1e"/>
|
||||
<stop offset="100%" stop-color="#2a2a2a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="selection" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3c82dc" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#3c82dc" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="eight-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd230"/>
|
||||
<stop offset="100%" stop-color="#e6a800"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="720" height="200" rx="12" fill="#161616"/>
|
||||
|
||||
<!-- Timeline track -->
|
||||
<rect x="40" y="100" width="640" height="48" rx="4" fill="url(#timeline-bg)" stroke="#333" stroke-width="1"/>
|
||||
|
||||
<!-- Timeline lane -->
|
||||
<rect x="40" y="112" width="640" height="24" rx="2" fill="#2a2a2a"/>
|
||||
|
||||
<!-- Selection region (8 seconds) -->
|
||||
<rect x="240" y="100" width="140" height="48" rx="2" fill="url(#selection)"/>
|
||||
<line x1="240" y1="100" x2="240" y2="148" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
|
||||
<line x1="380" y1="100" x2="380" y2="148" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
|
||||
|
||||
<!-- Playhead -->
|
||||
<line x1="240" y1="96" x2="240" y2="152" stroke="#ffd230" stroke-width="2"/>
|
||||
<polygon points="234,96 246,96 240,104" fill="#ffd230"/>
|
||||
|
||||
<!-- Ruler ticks -->
|
||||
<g stroke="#555" stroke-width="1">
|
||||
<line x1="80" y1="100" x2="80" y2="108"/>
|
||||
<line x1="120" y1="100" x2="120" y2="105"/>
|
||||
<line x1="160" y1="100" x2="160" y2="108"/>
|
||||
<line x1="200" y1="100" x2="200" y2="105"/>
|
||||
<line x1="240" y1="100" x2="240" y2="108"/>
|
||||
<line x1="280" y1="100" x2="280" y2="105"/>
|
||||
<line x1="320" y1="100" x2="320" y2="108"/>
|
||||
<line x1="360" y1="100" x2="360" y2="105"/>
|
||||
<line x1="400" y1="100" x2="400" y2="108"/>
|
||||
<line x1="440" y1="100" x2="440" y2="105"/>
|
||||
<line x1="480" y1="100" x2="480" y2="108"/>
|
||||
<line x1="520" y1="100" x2="520" y2="105"/>
|
||||
<line x1="560" y1="100" x2="560" y2="108"/>
|
||||
<line x1="600" y1="100" x2="600" y2="105"/>
|
||||
<line x1="640" y1="100" x2="640" y2="108"/>
|
||||
</g>
|
||||
|
||||
<!-- Export markers -->
|
||||
<g>
|
||||
<line x1="130" y1="100" x2="130" y2="148" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="130" y="102" width="14" height="12" rx="1" fill="#c83232"/>
|
||||
<text x="137" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">1</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="390" y1="100" x2="390" y2="148" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="390" y="102" width="14" height="12" rx="1" fill="#c83232"/>
|
||||
<text x="397" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">2</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="540" y1="100" x2="540" y2="148" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="540" y="102" width="14" height="12" rx="1" fill="#c83232"/>
|
||||
<text x="547" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">3</text>
|
||||
</g>
|
||||
|
||||
<!-- "8" numeral -->
|
||||
<text x="100" y="72" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="72" font-weight="bold" fill="url(#eight-grad)">8</text>
|
||||
|
||||
<!-- "-cut" text -->
|
||||
<text x="148" y="70" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="48" font-weight="300" fill="#cccccc">-cut</text>
|
||||
|
||||
<!-- Scissors icon near playhead -->
|
||||
<g transform="translate(296, 82) scale(0.7)" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="5" cy="5" r="4" />
|
||||
<circle cx="5" cy="19" r="4" />
|
||||
<line x1="9" y1="7" x2="20" y2="17"/>
|
||||
<line x1="9" y1="17" x2="20" y2="7"/>
|
||||
</g>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="400" y="72" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="14" fill="#777">8-second clips for foley datasets</text>
|
||||
|
||||
<!-- Duration label in selection -->
|
||||
<text x="310" y="130" font-family="'Courier New', monospace" font-size="13" fill="#aad4ff" text-anchor="middle" opacity="0.9">8.0s</text>
|
||||
|
||||
<!-- Time labels -->
|
||||
<text x="40" y="166" font-family="'Courier New', monospace" font-size="10" fill="#666">0:00</text>
|
||||
<text x="230" y="166" font-family="'Courier New', monospace" font-size="10" fill="#e6a800">1:15</text>
|
||||
<text x="640" y="166" font-family="'Courier New', monospace" font-size="10" fill="#666">5:00</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 260">
|
||||
<defs>
|
||||
<marker id="al" markerWidth="6" markerHeight="4" refX="0" refY="2" orient="auto">
|
||||
<path d="M6,0 L0,2 L6,4" fill="#777"/>
|
||||
</marker>
|
||||
<marker id="ar" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto">
|
||||
<path d="M0,0 L6,2 L0,4" fill="#777"/>
|
||||
</marker>
|
||||
<marker id="gl" markerWidth="6" markerHeight="4" refX="0" refY="2" orient="auto">
|
||||
<path d="M6,0 L0,2 L6,4" fill="#e6a800"/>
|
||||
</marker>
|
||||
<marker id="gr" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto">
|
||||
<path d="M0,0 L6,2 L0,4" fill="#e6a800"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="720" height="260" rx="12" fill="#161616"/>
|
||||
|
||||
<!-- Cursor / playhead -->
|
||||
<text x="110" y="32" font-family="'Courier New', monospace" font-size="11" fill="#e6a800" text-anchor="middle">cursor</text>
|
||||
<polygon points="104,38 116,38 110,46" fill="#ffd230"/>
|
||||
<line x1="110" y1="46" x2="110" y2="195" stroke="#ffd230" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
|
||||
<!-- Dashed guide lines at clip start offsets -->
|
||||
<line x1="212" y1="56" x2="212" y2="195" stroke="#444" stroke-width="1" stroke-dasharray="2,4"/>
|
||||
<line x1="314" y1="56" x2="314" y2="195" stroke="#444" stroke-width="1" stroke-dasharray="2,4"/>
|
||||
|
||||
<!-- Clip 0 -->
|
||||
<text x="20" y="74" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_0</text>
|
||||
<rect x="110" y="56" width="272" height="28" rx="4" fill="#3c82dc" opacity="0.9"/>
|
||||
<text x="246" y="74" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
|
||||
|
||||
<!-- Spread annotation 1 -->
|
||||
<line x1="110" y1="88" x2="110" y2="104" stroke="#666" stroke-width="1"/>
|
||||
<line x1="212" y1="88" x2="212" y2="104" stroke="#666" stroke-width="1"/>
|
||||
<line x1="114" y1="96" x2="208" y2="96" stroke="#777" stroke-width="1" marker-start="url(#al)" marker-end="url(#ar)"/>
|
||||
<text x="161" y="93" font-family="'Courier New', monospace" font-size="9" fill="#999" text-anchor="middle">spread 3s</text>
|
||||
|
||||
<!-- Clip 1 -->
|
||||
<text x="20" y="126" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_1</text>
|
||||
<rect x="212" y="108" width="272" height="28" rx="4" fill="#5a9be6" opacity="0.85"/>
|
||||
<text x="348" y="126" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
|
||||
|
||||
<!-- Spread annotation 2 -->
|
||||
<line x1="212" y1="140" x2="212" y2="156" stroke="#666" stroke-width="1"/>
|
||||
<line x1="314" y1="140" x2="314" y2="156" stroke="#666" stroke-width="1"/>
|
||||
<line x1="216" y1="148" x2="310" y2="148" stroke="#777" stroke-width="1" marker-start="url(#al)" marker-end="url(#ar)"/>
|
||||
<text x="263" y="145" font-family="'Courier New', monospace" font-size="9" fill="#999" text-anchor="middle">spread 3s</text>
|
||||
|
||||
<!-- Clip 2 -->
|
||||
<text x="20" y="178" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_2</text>
|
||||
<rect x="314" y="160" width="272" height="28" rx="4" fill="#78b4f0" opacity="0.8"/>
|
||||
<text x="450" y="178" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
|
||||
|
||||
<!-- Total span annotation -->
|
||||
<line x1="110" y1="198" x2="110" y2="218" stroke="#e6a800" stroke-width="1"/>
|
||||
<line x1="586" y1="198" x2="586" y2="218" stroke="#e6a800" stroke-width="1"/>
|
||||
<line x1="114" y1="210" x2="582" y2="210" stroke="#e6a800" stroke-width="1" marker-start="url(#gl)" marker-end="url(#gr)"/>
|
||||
<text x="348" y="235" font-family="'Courier New', monospace" font-size="12" fill="#e6a800" text-anchor="middle">total span: 14s</text>
|
||||
<text x="348" y="250" font-family="'Courier New', monospace" font-size="10" fill="#666" text-anchor="middle">8 + (n−1) × spread</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,132 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#141414"/>
|
||||
<stop offset="100%" stop-color="#1a1a1a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tl-bg" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e1e1e"/>
|
||||
<stop offset="100%" stop-color="#262626"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sel" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3c82dc" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#3c82dc" stop-opacity="0.2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="eight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd230"/>
|
||||
<stop offset="100%" stop-color="#d4a000"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glow" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd230" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffd230" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1280" height="640" fill="url(#bg)"/>
|
||||
|
||||
<!-- Subtle glow behind title -->
|
||||
<ellipse cx="640" cy="200" rx="400" ry="140" fill="url(#glow)"/>
|
||||
|
||||
<!-- Title: "8" -->
|
||||
<text x="440" y="250" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="180" font-weight="bold" fill="url(#eight)">8</text>
|
||||
|
||||
<!-- Title: "-cut" -->
|
||||
<text x="560" y="244" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="110" font-weight="300" fill="#cccccc">-cut</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="640" y="310" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="26" fill="#777" text-anchor="middle">8-second video clips for foley datasets</text>
|
||||
|
||||
<!-- ===== Timeline ===== -->
|
||||
|
||||
<!-- Timeline track -->
|
||||
<rect x="120" y="380" width="1040" height="72" rx="6" fill="url(#tl-bg)" stroke="#333" stroke-width="1"/>
|
||||
|
||||
<!-- Timeline lane -->
|
||||
<rect x="120" y="398" width="1040" height="36" rx="3" fill="#2a2a2a"/>
|
||||
|
||||
<!-- Ruler ticks -->
|
||||
<g stroke="#555" stroke-width="1">
|
||||
<line x1="172" y1="380" x2="172" y2="392"/>
|
||||
<line x1="224" y1="380" x2="224" y2="387"/>
|
||||
<line x1="276" y1="380" x2="276" y2="392"/>
|
||||
<line x1="328" y1="380" x2="328" y2="387"/>
|
||||
<line x1="380" y1="380" x2="380" y2="392"/>
|
||||
<line x1="432" y1="380" x2="432" y2="387"/>
|
||||
<line x1="484" y1="380" x2="484" y2="392"/>
|
||||
<line x1="536" y1="380" x2="536" y2="387"/>
|
||||
<line x1="588" y1="380" x2="588" y2="392"/>
|
||||
<line x1="640" y1="380" x2="640" y2="387"/>
|
||||
<line x1="692" y1="380" x2="692" y2="392"/>
|
||||
<line x1="744" y1="380" x2="744" y2="387"/>
|
||||
<line x1="796" y1="380" x2="796" y2="392"/>
|
||||
<line x1="848" y1="380" x2="848" y2="387"/>
|
||||
<line x1="900" y1="380" x2="900" y2="392"/>
|
||||
<line x1="952" y1="380" x2="952" y2="387"/>
|
||||
<line x1="1004" y1="380" x2="1004" y2="392"/>
|
||||
<line x1="1056" y1="380" x2="1056" y2="387"/>
|
||||
<line x1="1108" y1="380" x2="1108" y2="392"/>
|
||||
</g>
|
||||
|
||||
<!-- Selection region -->
|
||||
<rect x="460" y="380" width="220" height="72" rx="2" fill="url(#sel)"/>
|
||||
<line x1="460" y1="380" x2="460" y2="452" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
|
||||
<line x1="680" y1="380" x2="680" y2="452" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
|
||||
|
||||
<!-- Playhead -->
|
||||
<line x1="460" y1="372" x2="460" y2="456" stroke="#ffd230" stroke-width="2.5"/>
|
||||
<polygon points="452,372 468,372 460,384" fill="#ffd230"/>
|
||||
|
||||
<!-- Duration label -->
|
||||
<text x="570" y="422" font-family="'Courier New', monospace" font-size="18" fill="#aad4ff" text-anchor="middle" opacity="0.8">8.0s</text>
|
||||
|
||||
<!-- Export markers -->
|
||||
<g>
|
||||
<line x1="260" y1="380" x2="260" y2="452" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="260" y="383" width="18" height="15" rx="2" fill="#c83232"/>
|
||||
<text x="269" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">1</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="700" y1="380" x2="700" y2="452" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="700" y="383" width="18" height="15" rx="2" fill="#c83232"/>
|
||||
<text x="709" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">2</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="940" y1="380" x2="940" y2="452" stroke="#dc3c3c" stroke-width="2"/>
|
||||
<rect x="940" y="383" width="18" height="15" rx="2" fill="#c83232"/>
|
||||
<text x="949" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">3</text>
|
||||
</g>
|
||||
|
||||
<!-- Scissors icon -->
|
||||
<g transform="translate(554, 356) scale(0.9)" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="5" cy="5" r="4"/>
|
||||
<circle cx="5" cy="19" r="4"/>
|
||||
<line x1="9" y1="7" x2="20" y2="17"/>
|
||||
<line x1="9" y1="17" x2="20" y2="7"/>
|
||||
</g>
|
||||
|
||||
<!-- Time labels below timeline -->
|
||||
<text x="120" y="476" font-family="'Courier New', monospace" font-size="13" fill="#555">0:00</text>
|
||||
<text x="448" y="476" font-family="'Courier New', monospace" font-size="13" fill="#e6a800">2:30</text>
|
||||
<text x="1110" y="476" font-family="'Courier New', monospace" font-size="13" fill="#555">8:20</text>
|
||||
|
||||
<!-- ===== Feature pills ===== -->
|
||||
<g font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="15">
|
||||
<!-- Pill backgrounds -->
|
||||
<rect x="260" y="520" width="120" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<rect x="400" y="520" width="130" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<rect x="550" y="520" width="120" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<rect x="690" y="520" width="140" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<rect x="850" y="520" width="130" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
|
||||
<!-- Pill text -->
|
||||
<text x="320" y="540" fill="#aaa" text-anchor="middle">Batch export</text>
|
||||
<text x="465" y="540" fill="#aaa" text-anchor="middle">Portrait crop</text>
|
||||
<text x="610" y="540" fill="#aaa" text-anchor="middle">Annotation</text>
|
||||
<text x="760" y="540" fill="#aaa" text-anchor="middle">Fuzzy matching</text>
|
||||
<text x="915" y="540" fill="#aaa" text-anchor="middle">WebP + WAV</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom border accent -->
|
||||
<rect x="0" y="636" width="1280" height="4" fill="#ffd230" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,46 @@
|
||||
# 8-cut Design
|
||||
|
||||
## Overview
|
||||
|
||||
A Linux desktop tool for dropping a video clip, setting a start point on a timeline, and exporting exactly 8 seconds to a numbered output file.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python + PyQt6** — native desktop window, drag & drop, custom timeline widget
|
||||
- **python-mpv** — embedded video playback with audio, frame-accurate seeking, AB loop for 8s preview
|
||||
- **ffmpeg** — export via subprocess, always re-encodes to guarantee exactly 8s with no freeze frames
|
||||
|
||||
## Layout
|
||||
|
||||
Single window, top to bottom:
|
||||
|
||||
1. Drop zone / loaded file path display
|
||||
2. mpv video embed (preview)
|
||||
3. Custom timeline widget with draggable cursor
|
||||
4. Playback controls + cursor position display
|
||||
5. Export controls: base name input, folder picker, next filename preview, Export button
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Drag & drop** a video file onto the window to load it
|
||||
- **Timeline**: click or drag to reposition cursor; shows current frame when paused
|
||||
- **Play 8s**: mpv seeks to cursor position, plays 8 seconds, loops back using mpv AB-loop (`ab-loop-a` = cursor, `ab-loop-b` = cursor + 8s)
|
||||
- **Pause**: shows current frame at cursor position
|
||||
- **Export**: runs ffmpeg in a QThread (non-blocking UI)
|
||||
- Command: `ffmpeg -ss {start} -i {input} -t 8 -c:v libx264 -c:a aac {output}`
|
||||
- Output: `{folder}/{basename}_{NNN:03d}.mp4`
|
||||
- Counter auto-increments after each successful export
|
||||
- Counter resets if base name or folder changes
|
||||
|
||||
## Architecture
|
||||
|
||||
Single file `main.py` (~300-400 lines):
|
||||
|
||||
- `MainWindow(QMainWindow)` — owns all state: file path, cursor (seconds), base name, output folder, export counter
|
||||
- `TimelineWidget(QWidget)` — custom `paintEvent` draws bar + cursor line; `mousePressEvent`/`mouseMoveEvent` for scrubbing
|
||||
- `MpvWidget(QWidget)` — embeds mpv using window ID (`wid`), exposes `load(path)`, `seek(t)`, `play_loop(a, b)`, `pause()`
|
||||
- `ExportWorker(QThread)` — runs ffmpeg subprocess, emits `finished(path)` or `error(msg)` signal
|
||||
|
||||
## Export guarantees
|
||||
|
||||
Always re-encode (never stream copy) to avoid freeze frames caused by keyframe misalignment at the cut point. Output is always exactly 8.000 seconds.
|
||||
@@ -0,0 +1,733 @@
|
||||
# 8-cut Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Linux desktop tool to drop a video, scrub a timeline, and export exactly 8 seconds to an auto-numbered output file.
|
||||
|
||||
**Architecture:** Single `main.py` file with PyQt6 for the window/widgets, python-mpv embedded for playback with AB-loop preview, and ffmpeg subprocess in a QThread for non-blocking export. Pure logic (filename counter, ffmpeg command builder) is tested with pytest; GUI is verified manually.
|
||||
|
||||
**Tech Stack:** Python 3.10+, PyQt6, python-mpv, ffmpeg (system), pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Project setup
|
||||
|
||||
**Files:**
|
||||
- Create: `main.py`
|
||||
- Create: `requirements.txt`
|
||||
- Create: `tests/__init__.py`
|
||||
- Create: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Install dependencies**
|
||||
|
||||
```bash
|
||||
pip install PyQt6 python-mpv pytest
|
||||
```
|
||||
|
||||
Verify mpv is on the system:
|
||||
```bash
|
||||
mpv --version
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
**Step 2: Create requirements.txt**
|
||||
|
||||
```
|
||||
PyQt6>=6.4
|
||||
python-mpv>=1.0
|
||||
pytest>=7.0
|
||||
```
|
||||
|
||||
**Step 3: Create tests/__init__.py**
|
||||
|
||||
Empty file.
|
||||
|
||||
**Step 4: Create main.py skeleton**
|
||||
|
||||
```python
|
||||
import sys
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("8-cut")
|
||||
self.resize(900, 650)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 5: Run to verify window opens**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Expected: empty 900×650 window titled "8-cut" appears.
|
||||
|
||||
**Step 6: Init git and commit**
|
||||
|
||||
```bash
|
||||
cd /media/p5/8-cut
|
||||
git init
|
||||
git add main.py requirements.txt tests/
|
||||
git commit -m "feat: project skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Pure utility functions (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `build_export_path`, `format_time`
|
||||
- Modify: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_utils.py
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from main import build_export_path, format_time
|
||||
|
||||
|
||||
def test_build_export_path_first():
|
||||
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
|
||||
|
||||
def test_build_export_path_counter():
|
||||
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
|
||||
|
||||
def test_build_export_path_deep_counter():
|
||||
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
|
||||
|
||||
def test_format_time_seconds():
|
||||
assert format_time(0.0) == "0:00.0"
|
||||
|
||||
def test_format_time_minutes():
|
||||
assert format_time(75.3) == "1:15.3"
|
||||
|
||||
def test_format_time_rounding():
|
||||
assert format_time(61.05) == "1:01.0"
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -v
|
||||
```
|
||||
|
||||
Expected: `ImportError` or `AttributeError` — functions not yet defined.
|
||||
|
||||
**Step 3: Add functions to main.py**
|
||||
|
||||
Add after the imports:
|
||||
|
||||
```python
|
||||
def build_export_path(folder: str, basename: str, counter: int) -> str:
|
||||
filename = f"{basename}_{counter:03d}.mp4"
|
||||
return os.path.join(folder, filename)
|
||||
|
||||
|
||||
def format_time(seconds: float) -> str:
|
||||
m = int(seconds) // 60
|
||||
s = seconds - m * 60
|
||||
return f"{m}:{s:04.1f}"
|
||||
```
|
||||
|
||||
Also add `import os` at the top of main.py.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -v
|
||||
```
|
||||
|
||||
Expected: all 6 tests PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: add utility functions with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ExportWorker (QThread)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `ExportWorker` class
|
||||
- Modify: `tests/test_utils.py` — add ffmpeg command test
|
||||
|
||||
**Step 1: Write failing test for command builder**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
from main import build_ffmpeg_command
|
||||
|
||||
def test_ffmpeg_command():
|
||||
cmd = build_ffmpeg_command("/in/video.mp4", 12.5, "/out/clip_001.mp4")
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-ss" in cmd
|
||||
assert str(12.5) in cmd
|
||||
assert "-t" in cmd
|
||||
assert "8" in cmd
|
||||
assert cmd[-1] == "/out/clip_001.mp4"
|
||||
```
|
||||
|
||||
**Step 2: Run to verify it fails**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py::test_ffmpeg_command -v
|
||||
```
|
||||
|
||||
Expected: ImportError.
|
||||
|
||||
**Step 3: Add build_ffmpeg_command and ExportWorker to main.py**
|
||||
|
||||
Add after imports:
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
def build_ffmpeg_command(input_path: str, start: float, output_path: str) -> list:
|
||||
return [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", "8",
|
||||
"-c:v", "libx264",
|
||||
"-c:a", "aac",
|
||||
output_path,
|
||||
]
|
||||
|
||||
|
||||
class ExportWorker(QThread):
|
||||
finished = pyqtSignal(str) # output path
|
||||
error = pyqtSignal(str) # error message
|
||||
|
||||
def __init__(self, input_path: str, start: float, output_path: str):
|
||||
super().__init__()
|
||||
self._input = input_path
|
||||
self._start = start
|
||||
self._output = output_path
|
||||
|
||||
def run(self):
|
||||
cmd = build_ffmpeg_command(self._input, self._start, self._output)
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode == 0:
|
||||
self.finished.emit(self._output)
|
||||
else:
|
||||
self.error.emit(result.stderr[-500:])
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -v
|
||||
```
|
||||
|
||||
Expected: all 7 tests PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: ExportWorker with ffmpeg command builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: TimelineWidget
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `TimelineWidget` class
|
||||
|
||||
**Step 1: Add TimelineWidget**
|
||||
|
||||
Add before `MainWindow`:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
|
||||
class TimelineWidget(QWidget):
|
||||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumHeight(40)
|
||||
self.setMouseTracking(True)
|
||||
self._duration = 0.0
|
||||
self._cursor = 0.0
|
||||
|
||||
def set_duration(self, duration: float):
|
||||
self._duration = duration
|
||||
self._cursor = 0.0
|
||||
self.update()
|
||||
|
||||
def set_cursor(self, seconds: float):
|
||||
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
|
||||
self.update()
|
||||
|
||||
def _pos_to_time(self, x: int) -> float:
|
||||
if self._duration <= 0 or self.width() <= 0:
|
||||
return 0.0
|
||||
ratio = max(0.0, min(1.0, x / self.width()))
|
||||
return ratio * self._duration
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
w, h = self.width(), self.height()
|
||||
|
||||
# Background
|
||||
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
|
||||
|
||||
if self._duration <= 0:
|
||||
return
|
||||
|
||||
# 8s selection highlight
|
||||
x_start = int(self._cursor / self._duration * w)
|
||||
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
|
||||
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
|
||||
|
||||
# Cursor line
|
||||
pen = QPen(QColor(255, 200, 0))
|
||||
pen.setWidth(2)
|
||||
p.setPen(pen)
|
||||
p.drawLine(x_start, 0, x_start, h)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._seek(event.position().x())
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons():
|
||||
self._seek(event.position().x())
|
||||
|
||||
def _seek(self, x: float):
|
||||
t = self._pos_to_time(int(x))
|
||||
self.set_cursor(t)
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
```
|
||||
|
||||
**Step 2: Verify it renders — quick smoke test**
|
||||
|
||||
Temporarily add to `MainWindow.__init__`:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
|
||||
container = QW()
|
||||
layout = QVBoxLayout(container)
|
||||
self._timeline = TimelineWidget()
|
||||
self._timeline.set_duration(60.0)
|
||||
layout.addWidget(self._timeline)
|
||||
self.setCentralWidget(container)
|
||||
```
|
||||
|
||||
Run `python main.py` — you should see a dark bar. Click/drag on it to move the yellow cursor with blue highlight.
|
||||
|
||||
**Step 3: Remove the temporary test code from MainWindow**
|
||||
|
||||
Revert `MainWindow.__init__` to just `super().__init__()`, `setWindowTitle`, `resize`.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: TimelineWidget with cursor and 8s highlight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: MpvWidget
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `MpvWidget` class
|
||||
|
||||
**Step 1: Add MpvWidget**
|
||||
|
||||
Add before `MainWindow`:
|
||||
|
||||
```python
|
||||
import mpv
|
||||
from PyQt6.QtWidgets import QFrame
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
|
||||
|
||||
class MpvWidget(QFrame):
|
||||
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumSize(640, 360)
|
||||
self.setStyleSheet("background: black;")
|
||||
# Required so Qt creates a real native window handle for mpv to embed into.
|
||||
# Without these, mpv opens a separate window instead of embedding.
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True)
|
||||
self._player = None
|
||||
|
||||
def _init_player(self):
|
||||
if self._player is not None:
|
||||
return
|
||||
self._player = mpv.MPV(
|
||||
wid=str(int(self.winId())),
|
||||
keep_open=True,
|
||||
pause=True,
|
||||
)
|
||||
# mpv fires events on its own thread; bounce to Qt thread via QTimer.
|
||||
@self._player.event_callback("file-loaded")
|
||||
def _on_file_loaded(event):
|
||||
QTimer.singleShot(0, self.file_loaded.emit)
|
||||
|
||||
def load(self, path: str):
|
||||
self._init_player()
|
||||
self._player.play(path)
|
||||
|
||||
def seek(self, t: float):
|
||||
if self._player:
|
||||
self._player.pause = True
|
||||
self._player.seek(t, "absolute")
|
||||
|
||||
def play_loop(self, a: float, b: float):
|
||||
if self._player:
|
||||
self._player["ab-loop-a"] = a
|
||||
self._player["ab-loop-b"] = b
|
||||
self._player.pause = False
|
||||
|
||||
def stop_loop(self):
|
||||
if self._player:
|
||||
# ab-loop-a/b are numeric properties — setting to "no" via dict
|
||||
# accessor throws TypeError. Disable loop via ab_loop_count instead.
|
||||
self._player.ab_loop_count = 0
|
||||
self._player.pause = True
|
||||
|
||||
def get_duration(self) -> float:
|
||||
if self._player:
|
||||
d = self._player.duration
|
||||
return d if d else 0.0
|
||||
return 0.0
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._player:
|
||||
self._player.terminate()
|
||||
super().closeEvent(event)
|
||||
```
|
||||
|
||||
**Step 2: Smoke test**
|
||||
|
||||
Temporarily in `MainWindow.__init__`:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
|
||||
container = QW()
|
||||
layout = QVBoxLayout(container)
|
||||
self._mpv = MpvWidget()
|
||||
layout.addWidget(self._mpv)
|
||||
self.setCentralWidget(container)
|
||||
# after show(), call: self._mpv.load("/path/to/any/video.mp4")
|
||||
```
|
||||
|
||||
Run, load a real video path. Should display and be paused.
|
||||
|
||||
**Step 3: Remove temporary test code**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: MpvWidget with seek and AB-loop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: MainWindow — full UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — implement complete `MainWindow`
|
||||
|
||||
**Step 1: Add all imports at top of main.py**
|
||||
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent
|
||||
import mpv
|
||||
```
|
||||
|
||||
**Step 2: Replace MainWindow with full implementation**
|
||||
|
||||
```python
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("8-cut")
|
||||
self.resize(900, 680)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# State
|
||||
self._file_path: str = ""
|
||||
self._cursor: float = 0.0
|
||||
self._export_counter: int = 1
|
||||
self._export_worker: ExportWorker | None = None
|
||||
|
||||
# Widgets
|
||||
self._mpv = MpvWidget()
|
||||
self._mpv.file_loaded.connect(self._after_load)
|
||||
self._timeline = TimelineWidget()
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
|
||||
self._lbl_file = QLabel("Drop a video file here")
|
||||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||||
|
||||
self._btn_play = QPushButton("▶ Play 8s")
|
||||
self._btn_play.setEnabled(False)
|
||||
self._btn_play.clicked.connect(self._on_play)
|
||||
|
||||
self._btn_pause = QPushButton("⏸ Pause")
|
||||
self._btn_pause.setEnabled(False)
|
||||
self._btn_pause.clicked.connect(self._on_pause)
|
||||
|
||||
self._lbl_cursor = QLabel("cursor: --")
|
||||
self._lbl_duration = QLabel("dur: --")
|
||||
|
||||
self._txt_name = QLineEdit("clip")
|
||||
self._txt_name.setPlaceholderText("base name")
|
||||
self._txt_name.setMaximumWidth(150)
|
||||
self._txt_name.textChanged.connect(self._reset_counter)
|
||||
|
||||
self._txt_folder = QLineEdit(str(Path.home()))
|
||||
self._btn_folder = QPushButton("Browse")
|
||||
self._btn_folder.clicked.connect(self._pick_folder)
|
||||
|
||||
self._lbl_next = QLabel()
|
||||
self._update_next_label()
|
||||
|
||||
self._btn_export = QPushButton("Export")
|
||||
self._btn_export.setEnabled(False)
|
||||
self._btn_export.clicked.connect(self._on_export)
|
||||
|
||||
# Layout
|
||||
top_bar = QHBoxLayout()
|
||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.addWidget(self._btn_play)
|
||||
controls.addWidget(self._btn_pause)
|
||||
controls.addStretch()
|
||||
controls.addWidget(self._lbl_cursor)
|
||||
controls.addWidget(self._lbl_duration)
|
||||
|
||||
export_row = QHBoxLayout()
|
||||
export_row.addWidget(QLabel("Name:"))
|
||||
export_row.addWidget(self._txt_name)
|
||||
export_row.addWidget(QLabel("Folder:"))
|
||||
export_row.addWidget(self._txt_folder, stretch=1)
|
||||
export_row.addWidget(self._btn_folder)
|
||||
export_row.addWidget(self._lbl_next)
|
||||
export_row.addWidget(self._btn_export)
|
||||
|
||||
root = QVBoxLayout()
|
||||
root.addLayout(top_bar)
|
||||
root.addWidget(self._mpv, stretch=1)
|
||||
root.addWidget(self._timeline)
|
||||
root.addLayout(controls)
|
||||
root.addLayout(export_row)
|
||||
|
||||
container = QWidget()
|
||||
container.setLayout(root)
|
||||
self.setCentralWidget(container)
|
||||
self.setStatusBar(QStatusBar())
|
||||
|
||||
# --- Drag & Drop ---
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
path = urls[0].toLocalFile()
|
||||
self._load_file(path)
|
||||
|
||||
def _load_file(self, path: str):
|
||||
self._file_path = path
|
||||
self._lbl_file.setText(os.path.basename(path))
|
||||
self._mpv.load(path)
|
||||
# _after_load is triggered by MpvWidget.file_loaded signal (connected in __init__)
|
||||
|
||||
def _after_load(self):
|
||||
dur = self._mpv.get_duration()
|
||||
self._timeline.set_duration(dur)
|
||||
self._cursor = 0.0
|
||||
self._lbl_duration.setText(f"dur: {format_time(dur)}")
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
|
||||
self._btn_play.setEnabled(True)
|
||||
self._btn_pause.setEnabled(True)
|
||||
self._btn_export.setEnabled(True)
|
||||
|
||||
# --- Playback ---
|
||||
|
||||
def _on_cursor_changed(self, t: float):
|
||||
self._cursor = t
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
|
||||
self._mpv.seek(t)
|
||||
|
||||
def _on_play(self):
|
||||
if not self._file_path:
|
||||
return
|
||||
self._mpv.play_loop(self._cursor, self._cursor + 8.0)
|
||||
|
||||
def _on_pause(self):
|
||||
self._mpv.stop_loop()
|
||||
self._mpv.seek(self._cursor)
|
||||
|
||||
# --- Export ---
|
||||
|
||||
def _pick_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
|
||||
if folder:
|
||||
self._txt_folder.setText(folder)
|
||||
self._reset_counter()
|
||||
|
||||
def _reset_counter(self):
|
||||
self._export_counter = 1
|
||||
self._update_next_label()
|
||||
|
||||
def _update_next_label(self):
|
||||
path = build_export_path(
|
||||
self._txt_folder.text(),
|
||||
self._txt_name.text() or "clip",
|
||||
self._export_counter,
|
||||
)
|
||||
self._lbl_next.setText(f"→ {os.path.basename(path)}")
|
||||
|
||||
def _on_export(self):
|
||||
if not self._file_path:
|
||||
return
|
||||
if self._export_worker and self._export_worker.isRunning():
|
||||
self.statusBar().showMessage("Export already running…")
|
||||
return
|
||||
|
||||
output = build_export_path(
|
||||
self._txt_folder.text(),
|
||||
self._txt_name.text() or "clip",
|
||||
self._export_counter,
|
||||
)
|
||||
self._btn_export.setEnabled(False)
|
||||
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}…")
|
||||
|
||||
self._export_worker = ExportWorker(self._file_path, self._cursor, output)
|
||||
self._export_worker.finished.connect(self._on_export_done)
|
||||
self._export_worker.error.connect(self._on_export_error)
|
||||
self._export_worker.start()
|
||||
|
||||
def _on_export_done(self, path: str):
|
||||
self._export_counter += 1
|
||||
self._update_next_label()
|
||||
self._btn_export.setEnabled(True)
|
||||
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
|
||||
|
||||
def _on_export_error(self, msg: str):
|
||||
self._btn_export.setEnabled(True)
|
||||
self.statusBar().showMessage(f"Export error: {msg}")
|
||||
```
|
||||
|
||||
**Step 3: Run the app**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Window opens with dark drop zone
|
||||
- Drop a video → preview appears, timeline shows duration
|
||||
- Drag cursor → video seeks to that frame
|
||||
- Click "▶ Play 8s" → 8-second loop plays with audio
|
||||
- Click "⏸ Pause" → pauses and seeks back to cursor
|
||||
- Click "Export" → exports clip_001.mp4 to home folder, counter becomes 2
|
||||
|
||||
**Step 4: Run all tests to confirm nothing broken**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all tests PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: complete MainWindow UI with playback and export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final polish
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — dark theme, minor UX
|
||||
|
||||
**Step 1: Add dark stylesheet to main()**
|
||||
|
||||
```python
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
app.setStyleSheet("""
|
||||
QWidget { background: #1e1e1e; color: #ddd; }
|
||||
QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; }
|
||||
QPushButton:hover { background: #444; }
|
||||
QPushButton:disabled { color: #555; }
|
||||
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
||||
QStatusBar { color: #aaa; }
|
||||
""")
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
```
|
||||
|
||||
**Step 2: Run and verify visuals**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Drop a video, scrub, export. Everything should look clean and dark.
|
||||
|
||||
**Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: dark theme, complete 8-cut tool"
|
||||
```
|
||||
@@ -0,0 +1,494 @@
|
||||
# Playlist & Processed-Files Database Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a file queue (playlist) that auto-advances after each export, and a SQLite database that warns when a newly loaded file is fuzzy-similar to one already processed.
|
||||
|
||||
**Architecture:** `ProcessedDB` wraps `sqlite3` with fuzzy matching via `difflib.SequenceMatcher` on normalized filenames (resolution/quality tags stripped). `PlaylistWidget` is a `QListWidget` subclass that owns the queue and emits `file_selected` on advance or click. `MainWindow` is wired to use both.
|
||||
|
||||
**Tech Stack:** Python built-ins only — `sqlite3`, `difflib`, `re`, `datetime`. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_normalize_filename` and `ProcessedDB` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `_normalize_filename`, `ProcessedDB`
|
||||
- Modify: `tests/test_utils.py` — add DB and normalization tests
|
||||
|
||||
**Step 1: Add imports at top of main.py**
|
||||
|
||||
Add to the existing imports:
|
||||
|
||||
```python
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from difflib import SequenceMatcher
|
||||
```
|
||||
|
||||
**Step 2: Write failing tests**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
import tempfile, os
|
||||
from main import _normalize_filename, ProcessedDB
|
||||
|
||||
|
||||
# --- _normalize_filename ---
|
||||
|
||||
def test_normalize_strips_extension():
|
||||
assert _normalize_filename("clip.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_resolution():
|
||||
assert _normalize_filename("clip_2160p.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_1080p():
|
||||
assert _normalize_filename("clip_1080p.mkv") == "clip"
|
||||
|
||||
def test_normalize_strips_multiple_tags():
|
||||
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
|
||||
|
||||
def test_normalize_lowercases():
|
||||
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
|
||||
|
||||
def test_normalize_collapses_separators():
|
||||
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
|
||||
|
||||
|
||||
# --- ProcessedDB ---
|
||||
|
||||
def test_db_add_and_find_exact():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4")
|
||||
assert db.find_similar("video.mp4") == "video.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_resolution_variant():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_s01e01_2160p.mkv")
|
||||
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("alpha.mp4")
|
||||
assert db.find_similar("completely_different_zzzz.mp4") is None
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_disabled_survives_bad_path():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
db.add("x.mp4") # must not raise
|
||||
assert db.find_similar("x.mp4") is None # gracefully returns None
|
||||
```
|
||||
|
||||
**Step 3: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "normalize or db" -v
|
||||
```
|
||||
|
||||
Expected: ImportError — functions not defined yet.
|
||||
|
||||
**Step 4: Add `_normalize_filename` to main.py**
|
||||
|
||||
Add after the existing imports, before `build_export_path`:
|
||||
|
||||
```python
|
||||
def _normalize_filename(filename: str) -> str:
|
||||
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
|
||||
name = os.path.splitext(filename)[0].lower()
|
||||
name = re.sub(
|
||||
r'\b(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
|
||||
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
|
||||
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)\b',
|
||||
'', name, flags=re.IGNORECASE,
|
||||
)
|
||||
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
|
||||
return name
|
||||
```
|
||||
|
||||
**Step 5: Add `ProcessedDB` to main.py**
|
||||
|
||||
Add after `_normalize_filename`:
|
||||
|
||||
```python
|
||||
class ProcessedDB:
|
||||
def __init__(self, db_path: str | None = None):
|
||||
if db_path is None:
|
||||
db_path = str(Path.home() / ".8cut.db")
|
||||
try:
|
||||
self._con = sqlite3.connect(db_path)
|
||||
self._con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS processed "
|
||||
"(filename TEXT NOT NULL, processed_at TEXT NOT NULL)"
|
||||
)
|
||||
self._con.commit()
|
||||
self._enabled = True
|
||||
except Exception as e:
|
||||
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
|
||||
self._con = None
|
||||
self._enabled = False
|
||||
|
||||
def add(self, filename: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
self._con.execute(
|
||||
"INSERT INTO processed (filename, processed_at) VALUES (?, ?)",
|
||||
(filename, datetime.utcnow().isoformat()),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def find_similar(self, filename: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT filename FROM processed"
|
||||
).fetchall()
|
||||
norm_new = _normalize_filename(filename)
|
||||
best_ratio, best_match = 0.0, None
|
||||
for (stored,) in rows:
|
||||
ratio = SequenceMatcher(
|
||||
None, norm_new, _normalize_filename(stored)
|
||||
).ratio()
|
||||
if ratio >= 0.75 and ratio > best_ratio:
|
||||
best_ratio, best_match = ratio, stored
|
||||
return best_match
|
||||
```
|
||||
|
||||
**Step 6: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "normalize or db" -v
|
||||
```
|
||||
|
||||
Expected: all 10 new tests PASS (plus existing 8 = 18 total).
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: ProcessedDB and _normalize_filename with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `PlaylistWidget`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `PlaylistWidget` class
|
||||
|
||||
**Step 1: Add PlaylistWidget before MainWindow**
|
||||
|
||||
Add after `MpvWidget`, before `main()`:
|
||||
|
||||
```python
|
||||
class PlaylistWidget(QListWidget):
|
||||
file_selected = pyqtSignal(str) # emits full path of selected file
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
|
||||
self.setFixedWidth(200)
|
||||
self.setWordWrap(True)
|
||||
self._paths: list[str] = []
|
||||
self.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
def add_files(self, paths: list[str]) -> None:
|
||||
"""Append paths not already in queue; auto-select first if queue was empty."""
|
||||
was_empty = len(self._paths) == 0
|
||||
for path in paths:
|
||||
if path not in self._paths and os.path.isfile(path):
|
||||
self._paths.append(path)
|
||||
self.addItem(os.path.basename(path))
|
||||
if was_empty and self._paths:
|
||||
self._select(0)
|
||||
|
||||
def advance(self) -> None:
|
||||
"""Move to next item in queue. Does nothing if at end."""
|
||||
row = self.currentRow()
|
||||
if row < self.count() - 1:
|
||||
self._select(row + 1)
|
||||
|
||||
def current_path(self) -> str | None:
|
||||
row = self.currentRow()
|
||||
return self._paths[row] if 0 <= row < len(self._paths) else None
|
||||
|
||||
def _select(self, row: int) -> None:
|
||||
self.setCurrentRow(row)
|
||||
self._refresh_labels()
|
||||
self.file_selected.emit(self._paths[row])
|
||||
|
||||
def _refresh_labels(self) -> None:
|
||||
current = self.currentRow()
|
||||
for i in range(self.count()):
|
||||
name = os.path.basename(self._paths[i])
|
||||
self.item(i).setText(f"▶ {name}" if i == current else name)
|
||||
|
||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||
self._select(self.row(item))
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None:
|
||||
paths = [
|
||||
u.toLocalFile() for u in event.mimeData().urls()
|
||||
if os.path.isfile(u.toLocalFile())
|
||||
]
|
||||
if paths:
|
||||
self.add_files(paths)
|
||||
```
|
||||
|
||||
**Step 2: Add missing imports**
|
||||
|
||||
`QListWidget`, `QListWidgetItem`, and `QAbstractItemView` need to be in the QtWidgets import line. Check which are missing and add them. The updated import line should be:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
|
||||
)
|
||||
```
|
||||
|
||||
(`QSplitter` is added now for use in Task 3.)
|
||||
|
||||
**Step 3: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import PlaylistWidget"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all 18 tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: PlaylistWidget with drop support and auto-advance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire MainWindow
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — update `MainWindow.__init__`, `_load_file`, `_after_load`, `_on_export_done`, layout
|
||||
|
||||
**Step 1: Update MainWindow.__init__**
|
||||
|
||||
Replace the `MainWindow.__init__` method entirely with the version below. Key changes:
|
||||
- Add `self._db = ProcessedDB()` and `self._playlist = PlaylistWidget()`
|
||||
- Connect `_playlist.file_selected` to `_load_file`
|
||||
- Change the root layout to a horizontal split: playlist on left, existing content on right
|
||||
- Remove `self.setAcceptDrops(True)` from MainWindow (playlist handles drops now)
|
||||
|
||||
```python
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("8-cut")
|
||||
self.resize(1100, 680)
|
||||
|
||||
# Services
|
||||
self._db = ProcessedDB()
|
||||
|
||||
# State
|
||||
self._file_path: str = ""
|
||||
self._cursor: float = 0.0
|
||||
self._export_counter: int = 1
|
||||
self._export_worker: ExportWorker | None = None
|
||||
|
||||
# Widgets
|
||||
self._playlist = PlaylistWidget()
|
||||
self._playlist.file_selected.connect(self._load_file)
|
||||
|
||||
self._mpv = MpvWidget()
|
||||
self._mpv.file_loaded.connect(self._after_load)
|
||||
self._timeline = TimelineWidget()
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
|
||||
self._lbl_file = QLabel("Drop files onto the queue →")
|
||||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||||
|
||||
self._btn_play = QPushButton("▶ Play 8s")
|
||||
self._btn_play.setEnabled(False)
|
||||
self._btn_play.clicked.connect(self._on_play)
|
||||
|
||||
self._btn_pause = QPushButton("⏸ Pause")
|
||||
self._btn_pause.setEnabled(False)
|
||||
self._btn_pause.clicked.connect(self._on_pause)
|
||||
|
||||
self._lbl_cursor = QLabel("cursor: --")
|
||||
self._lbl_duration = QLabel("dur: --")
|
||||
|
||||
self._txt_name = QLineEdit("clip")
|
||||
self._txt_name.setPlaceholderText("base name")
|
||||
self._txt_name.setMaximumWidth(150)
|
||||
self._txt_name.textChanged.connect(self._reset_counter)
|
||||
|
||||
self._txt_folder = QLineEdit(str(Path.home()))
|
||||
self._txt_folder.textChanged.connect(self._reset_counter)
|
||||
self._btn_folder = QPushButton("Browse")
|
||||
self._btn_folder.clicked.connect(self._pick_folder)
|
||||
|
||||
self._lbl_next = QLabel()
|
||||
self._update_next_label()
|
||||
|
||||
self._btn_export = QPushButton("Export")
|
||||
self._btn_export.setEnabled(False)
|
||||
self._btn_export.clicked.connect(self._on_export)
|
||||
|
||||
# Right-side layout (video + controls)
|
||||
top_bar = QHBoxLayout()
|
||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.addWidget(self._btn_play)
|
||||
controls.addWidget(self._btn_pause)
|
||||
controls.addStretch()
|
||||
controls.addWidget(self._lbl_cursor)
|
||||
controls.addWidget(self._lbl_duration)
|
||||
|
||||
export_row = QHBoxLayout()
|
||||
export_row.addWidget(QLabel("Name:"))
|
||||
export_row.addWidget(self._txt_name)
|
||||
export_row.addWidget(QLabel("Folder:"))
|
||||
export_row.addWidget(self._txt_folder, stretch=1)
|
||||
export_row.addWidget(self._btn_folder)
|
||||
export_row.addWidget(self._lbl_next)
|
||||
export_row.addWidget(self._btn_export)
|
||||
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.addLayout(top_bar)
|
||||
right_layout.addWidget(self._mpv, stretch=1)
|
||||
right_layout.addWidget(self._timeline)
|
||||
right_layout.addLayout(controls)
|
||||
right_layout.addLayout(export_row)
|
||||
|
||||
# Left: playlist label + list
|
||||
queue_label = QLabel("Queue")
|
||||
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(4, 4, 4, 4)
|
||||
left_layout.addWidget(queue_label)
|
||||
left_layout.addWidget(self._playlist)
|
||||
|
||||
# Root: horizontal split
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.addWidget(left)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([200, 900])
|
||||
splitter.setCollapsible(0, False)
|
||||
splitter.setCollapsible(1, False)
|
||||
|
||||
self.setCentralWidget(splitter)
|
||||
self.setStatusBar(QStatusBar())
|
||||
```
|
||||
|
||||
**Step 2: Update `_load_file` — remove isfile guard (PlaylistWidget already filters)**
|
||||
|
||||
```python
|
||||
def _load_file(self, path: str):
|
||||
self._file_path = path
|
||||
self._lbl_file.setText(os.path.basename(path))
|
||||
self._mpv.load(path)
|
||||
# _after_load triggered by MpvWidget.file_loaded signal
|
||||
```
|
||||
|
||||
**Step 3: Update `_after_load` — add DB similarity check**
|
||||
|
||||
```python
|
||||
def _after_load(self):
|
||||
dur = self._mpv.get_duration()
|
||||
self._timeline.set_duration(dur)
|
||||
self._cursor = 0.0
|
||||
self._lbl_duration.setText(f"dur: {format_time(dur)}")
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
|
||||
self._btn_play.setEnabled(True)
|
||||
self._btn_pause.setEnabled(True)
|
||||
self._btn_export.setEnabled(True)
|
||||
|
||||
match = self._db.find_similar(os.path.basename(self._file_path))
|
||||
if match:
|
||||
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
|
||||
```
|
||||
|
||||
**Step 4: Update `_on_export_done` — record to DB and advance queue**
|
||||
|
||||
```python
|
||||
def _on_export_done(self, path: str):
|
||||
self._db.add(os.path.basename(self._file_path))
|
||||
self._export_counter += 1
|
||||
self._update_next_label()
|
||||
self._btn_export.setEnabled(True)
|
||||
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
|
||||
self._playlist.advance()
|
||||
```
|
||||
|
||||
**Step 5: Remove `dragEnterEvent` and `dropEvent` from MainWindow**
|
||||
|
||||
These methods are no longer needed on `MainWindow` — drops go directly to `PlaylistWidget`. Delete both methods.
|
||||
|
||||
**Step 6: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import MainWindow"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 7: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all 18 tests pass.
|
||||
|
||||
**Step 8: Manual smoke test**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
- Drop one or more video files onto the queue panel → they appear in the list
|
||||
- First file loads automatically into the player
|
||||
- Scrub, play, pause — all work as before
|
||||
- Export → file saved, counter increments, next file in queue loads automatically
|
||||
- Drop the same file again → `⚠ Similar to already processed:` appears in status bar
|
||||
|
||||
**Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: wire playlist and DB into MainWindow"
|
||||
```
|
||||
@@ -0,0 +1,430 @@
|
||||
# Timeline Markers Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Show numbered markers on the timeline at positions where clips were previously extracted from the current source file, with a hover tooltip showing the output path.
|
||||
|
||||
**Architecture:** DB schema is migrated to store `start_time` and `output_path` per export row (dropping the old UNIQUE constraint). `TimelineWidget` gains `set_markers()`, draws red numbered lines in `paintEvent`, and shows `QToolTip` on hover. `MainWindow` feeds markers to the timeline on load and after each export.
|
||||
|
||||
**Tech Stack:** Python built-ins (`sqlite3`, `re`, `difflib`), PyQt6 (`QToolTip`, `QCursor`, `QFont`). No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: DB schema migration and new methods (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — update `ProcessedDB.__init__`, `add`, add `get_markers`
|
||||
- Modify: `tests/test_utils.py` — update existing DB tests, add marker tests
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Replace the four existing `test_db_*` tests and add new ones in `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
# --- ProcessedDB (updated) ---
|
||||
|
||||
def test_db_add_and_find_exact():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
|
||||
assert db.find_similar("video.mp4") == "video.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_resolution_variant():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
|
||||
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
|
||||
assert db.find_similar("completely_different_zzzz.mp4") is None
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_disabled_survives_bad_path():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
|
||||
assert db.find_similar("x.mp4") is None
|
||||
|
||||
def test_db_get_markers_returns_sorted():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 30.0, "/out/clip_002.mp4")
|
||||
db.add("video.mp4", 10.0, "/out/clip_001.mp4")
|
||||
db.add("video.mp4", 50.0, "/out/clip_003.mp4")
|
||||
markers = db.get_markers("video.mp4")
|
||||
assert len(markers) == 3
|
||||
assert markers[0] == (10.0, 1, "/out/clip_001.mp4")
|
||||
assert markers[1] == (30.0, 2, "/out/clip_002.mp4")
|
||||
assert markers[2] == (50.0, 3, "/out/clip_003.mp4")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_fuzzy_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
|
||||
markers = db.get_markers("show_1080p.mkv")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 5.0
|
||||
assert markers[0][2] == "/out/s_001.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
markers = db.get_markers("nothing.mp4")
|
||||
assert markers == []
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_disabled():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
assert db.get_markers("x.mp4") == []
|
||||
```
|
||||
|
||||
**Step 2: Run to verify they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "db" -v
|
||||
```
|
||||
|
||||
Expected: failures — `add` has wrong signature, `get_markers` doesn't exist.
|
||||
|
||||
**Step 3: Update `ProcessedDB` in main.py**
|
||||
|
||||
Replace the entire `ProcessedDB` class:
|
||||
|
||||
```python
|
||||
class ProcessedDB:
|
||||
_SCHEMA_VERSION = 2 # bump when schema changes
|
||||
|
||||
def __init__(self, db_path: str | None = None):
|
||||
if db_path is None:
|
||||
db_path = str(Path.home() / ".8cut.db")
|
||||
try:
|
||||
self._con = sqlite3.connect(db_path)
|
||||
self._migrate()
|
||||
self._enabled = True
|
||||
except Exception as e:
|
||||
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
|
||||
self._con = None
|
||||
self._enabled = False
|
||||
|
||||
def _migrate(self) -> None:
|
||||
"""Create or recreate table if schema is outdated."""
|
||||
cols = {
|
||||
row[1]
|
||||
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
|
||||
}
|
||||
needs_recreate = "start_time" not in cols or "output_path" not in cols
|
||||
if needs_recreate:
|
||||
self._con.execute("DROP TABLE IF EXISTS processed")
|
||||
self._con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS processed ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" filename TEXT NOT NULL,"
|
||||
" start_time REAL NOT NULL,"
|
||||
" output_path TEXT NOT NULL,"
|
||||
" processed_at TEXT NOT NULL"
|
||||
")"
|
||||
)
|
||||
self._con.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def add(self, filename: str, start_time: float, output_path: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
self._con.execute(
|
||||
"INSERT INTO processed (filename, start_time, output_path, processed_at)"
|
||||
" VALUES (?, ?, ?, ?)",
|
||||
(filename, start_time, output_path, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def find_similar(self, filename: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT filename FROM processed"
|
||||
).fetchall()
|
||||
norm_new = _normalize_filename(filename)
|
||||
best_ratio, best_match = 0.0, None
|
||||
for (stored,) in rows:
|
||||
ratio = SequenceMatcher(
|
||||
None, norm_new, _normalize_filename(stored)
|
||||
).ratio()
|
||||
if ratio >= 0.75 and ratio > best_ratio:
|
||||
best_ratio, best_match = ratio, stored
|
||||
return best_match
|
||||
|
||||
def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
|
||||
"""Return [(start_time, marker_number, output_path), ...] for the best
|
||||
fuzzy match of filename, sorted by start_time. Empty list if no match."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
match = self.find_similar(filename)
|
||||
if match is None:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path FROM processed"
|
||||
" WHERE filename = ? ORDER BY start_time",
|
||||
(match,),
|
||||
).fetchall()
|
||||
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
|
||||
```
|
||||
|
||||
**Step 4: Run DB tests**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "db" -v
|
||||
```
|
||||
|
||||
Expected: all 8 DB tests PASS.
|
||||
|
||||
**Step 5: Run full suite**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all tests pass (normalize tests + db tests = 14 DB/normalize tests + 8 original = 22 total — count may vary).
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: DB schema v2 — store start_time and output_path, add get_markers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TimelineWidget markers (paint + hover)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — update `TimelineWidget`
|
||||
|
||||
**Step 1: Add missing imports to main.py**
|
||||
|
||||
`QToolTip` and `QCursor` are needed. Add them to the existing imports:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import (
|
||||
...existing..., QToolTip,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
...existing..., QCursor, QFont,
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Add `set_markers` and update `paintEvent` and `mouseMoveEvent`**
|
||||
|
||||
In `TimelineWidget`, add the `_markers` attribute in `__init__`:
|
||||
|
||||
```python
|
||||
self._markers: list[tuple[float, int, str]] = []
|
||||
```
|
||||
|
||||
Add the `set_markers` method:
|
||||
|
||||
```python
|
||||
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
|
||||
"""markers: list of (start_time, number, output_path)"""
|
||||
self._markers = markers
|
||||
self.update()
|
||||
```
|
||||
|
||||
Replace `paintEvent` with:
|
||||
|
||||
```python
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
try:
|
||||
w, h = self.width(), self.height()
|
||||
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
|
||||
|
||||
if self._duration <= 0:
|
||||
return
|
||||
|
||||
# 8s selection highlight
|
||||
x_start = int(self._cursor / self._duration * w)
|
||||
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
|
||||
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
|
||||
|
||||
# Cursor line
|
||||
pen = QPen(QColor(255, 200, 0))
|
||||
pen.setWidth(2)
|
||||
p.setPen(pen)
|
||||
p.drawLine(x_start, 0, x_start, h)
|
||||
|
||||
# Markers
|
||||
font = QFont()
|
||||
font.setPixelSize(9)
|
||||
p.setFont(font)
|
||||
marker_pen = QPen(QColor(220, 60, 60))
|
||||
marker_pen.setWidth(2)
|
||||
for (t, num, _path) in self._markers:
|
||||
if self._duration <= 0:
|
||||
break
|
||||
mx = int(t / self._duration * w)
|
||||
p.setPen(marker_pen)
|
||||
p.drawLine(mx, 0, mx, h)
|
||||
p.setPen(QColor(255, 255, 255))
|
||||
p.drawText(mx + 2, 10, str(num))
|
||||
finally:
|
||||
p.end()
|
||||
```
|
||||
|
||||
Replace `mouseMoveEvent` with:
|
||||
|
||||
```python
|
||||
def mouseMoveEvent(self, event):
|
||||
x = event.position().x()
|
||||
# Check marker hover (±4px)
|
||||
if self._duration > 0 and self._markers:
|
||||
w = self.width()
|
||||
for (t, _num, output_path) in self._markers:
|
||||
mx = t / self._duration * w
|
||||
if abs(x - mx) <= 4:
|
||||
QToolTip.showText(QCursor.pos(), output_path, self)
|
||||
if event.buttons():
|
||||
self._seek(x)
|
||||
return
|
||||
QToolTip.hideText()
|
||||
if event.buttons():
|
||||
self._seek(x)
|
||||
```
|
||||
|
||||
**Step 3: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import TimelineWidget"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: timeline markers with hover tooltip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire MainWindow
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — update `_after_load`, `_on_export_done`, add `_refresh_markers`
|
||||
|
||||
**Step 1: Add `_refresh_markers` helper to MainWindow**
|
||||
|
||||
Add this method after `_after_load`:
|
||||
|
||||
```python
|
||||
def _refresh_markers(self) -> None:
|
||||
markers = self._db.get_markers(os.path.basename(self._file_path))
|
||||
self._timeline.set_markers(markers)
|
||||
```
|
||||
|
||||
**Step 2: Update `_after_load`**
|
||||
|
||||
Add `self._refresh_markers()` at the end of `_after_load`:
|
||||
|
||||
```python
|
||||
def _after_load(self):
|
||||
dur = self._mpv.get_duration()
|
||||
self._timeline.set_duration(dur)
|
||||
self._cursor = 0.0
|
||||
self._lbl_duration.setText(f"dur: {format_time(dur)}")
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
|
||||
self._btn_play.setEnabled(True)
|
||||
self._btn_pause.setEnabled(True)
|
||||
self._btn_export.setEnabled(True)
|
||||
|
||||
match = self._db.find_similar(os.path.basename(self._file_path))
|
||||
if match:
|
||||
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
|
||||
else:
|
||||
self.statusBar().clearMessage()
|
||||
|
||||
self._refresh_markers()
|
||||
```
|
||||
|
||||
**Step 3: Update `_on_export_done`**
|
||||
|
||||
Pass `start_time` and `output_path` to `db.add`, then refresh markers:
|
||||
|
||||
```python
|
||||
def _on_export_done(self, path: str):
|
||||
self._db.add(os.path.basename(self._file_path), self._cursor, path)
|
||||
self._export_counter += 1
|
||||
self._update_next_label()
|
||||
self._btn_export.setEnabled(True)
|
||||
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
|
||||
self._refresh_markers()
|
||||
self._playlist.advance()
|
||||
```
|
||||
|
||||
**Step 4: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import MainWindow"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 5: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
**Step 6: Manual smoke test**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
- Drop a video, set cursor, export → a red numbered marker `1` appears on the timeline at that position
|
||||
- Export again at a different position → marker `2` appears
|
||||
- Hover over a marker → tooltip shows the output file path
|
||||
- Drop a resolution variant of the same video → markers from the original appear immediately
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: wire timeline markers into MainWindow"
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# Keyframe crop modes design
|
||||
|
||||
## Problem
|
||||
|
||||
Currently, crop keyframes only store position (time, center). The random portrait and random square checkboxes apply globally to the entire batch. When a batch spans a scene change (e.g. wide landscape to close-up), portrait crop may only make sense for part of the span.
|
||||
|
||||
## Solution
|
||||
|
||||
Extend keyframes to snapshot the full crop state — position, ratio, and random crop flags — so each sub-clip in a batch inherits crop settings from the latest keyframe at or before its start time.
|
||||
|
||||
## Keyframe data
|
||||
|
||||
Expand from `(time, center)` to `(time, center, ratio, rand_portrait, rand_square)`:
|
||||
|
||||
- `time` (float) — absolute time in seconds
|
||||
- `center` (float) — horizontal crop position, 0.0 to 1.0
|
||||
- `ratio` (str | None) — portrait combo value: `None`, `"9:16"`, `"4:5"`, or `"1:1"`
|
||||
- `rand_portrait` (bool) — random portrait checkbox state
|
||||
- `rand_square` (bool) — random square checkbox state
|
||||
|
||||
## Setting keyframes
|
||||
|
||||
Same interaction as today: click the crop bar while in lock mode. The click now snapshots the current center, portrait combo selection, rand_portrait checkbox, and rand_square checkbox into the keyframe.
|
||||
|
||||
## Export application
|
||||
|
||||
For each sub-clip job:
|
||||
|
||||
1. Find the latest keyframe where `kt <= start_time + 0.05`.
|
||||
2. Apply its `center` and `ratio` to the job.
|
||||
3. Collect the effective `rand_portrait` and `rand_square` flags.
|
||||
4. After all keyframes are resolved, apply random crop selection only to sub-clips whose effective flags are set. The random selection (`n_random = max(1, eligible_count // 3)`) operates within each flag group independently.
|
||||
|
||||
When no keyframes exist, behavior is unchanged (global checkboxes apply to all clips).
|
||||
|
||||
## Timeline diamond colors
|
||||
|
||||
Each keyframe diamond on the timeline is color-coded by its random crop flags:
|
||||
|
||||
- No random flags — gold (current color, `#ffb400`)
|
||||
- Portrait only — red (`QColor(220, 60, 60)`)
|
||||
- Square only — blue (`QColor(60, 180, 220)`)
|
||||
- Both — split diamond: left half red, right half blue
|
||||
|
||||
## Playback preview in lock mode
|
||||
|
||||
When scrubbing in lock mode, `_on_seek_changed` already updates the crop bar preview from keyframes. This extends to also update the portrait combo and random checkboxes to reflect the effective state at the current playback position, so the user sees what each region's settings are.
|
||||
|
||||
## Clearing
|
||||
|
||||
Toggling lock off clears all keyframes (existing behavior, unchanged).
|
||||
@@ -0,0 +1,634 @@
|
||||
# Keyframe Crop Modes Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extend crop keyframes to snapshot ratio and random crop flags, so different sub-clips in a batch inherit different crop settings based on timeline position.
|
||||
|
||||
**Architecture:** Widen the keyframe tuple from `(time, center)` to `(time, center, ratio, rand_portrait, rand_square)`. All existing keyframe code paths (set, delete, clear, render, apply-at-export, preview-on-scrub) are updated to carry and use the new fields. Diamond rendering on the timeline uses color to indicate which random flags are set.
|
||||
|
||||
**Tech Stack:** Python, PyQt6 (QPainter for diamonds)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add a helper to resolve the effective keyframe at a given time
|
||||
|
||||
The keyframe lookup pattern (iterate sorted list, take latest where `kt <= t + 0.05`) is repeated 3 times in main.py. Extract it as a pure function so we can test it and reuse it cleanly with the new wider tuple.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:48-53` (module-level functions area)
|
||||
- Test: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
from main import resolve_keyframe
|
||||
|
||||
def test_resolve_keyframe_empty():
|
||||
assert resolve_keyframe([], 5.0) is None
|
||||
|
||||
def test_resolve_keyframe_before_first():
|
||||
kfs = [(3.0, 0.5, None, False, False)]
|
||||
assert resolve_keyframe(kfs, 1.0) is None
|
||||
|
||||
def test_resolve_keyframe_exact():
|
||||
kfs = [(2.0, 0.3, "9:16", True, False)]
|
||||
assert resolve_keyframe(kfs, 2.0) == (2.0, 0.3, "9:16", True, False)
|
||||
|
||||
def test_resolve_keyframe_between():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 3.0) == (1.0, 0.2, None, False, False)
|
||||
|
||||
def test_resolve_keyframe_after_last():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 10.0) == (5.0, 0.8, "1:1", False, True)
|
||||
|
||||
def test_resolve_keyframe_tolerance():
|
||||
kfs = [(4.0, 0.5, None, True, True)]
|
||||
# 4.0 <= 3.96 + 0.05 = 4.01, so it should match
|
||||
assert resolve_keyframe(kfs, 3.96) == (4.0, 0.5, None, True, True)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
|
||||
Expected: FAIL (ImportError — function does not exist yet)
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
Add to `main.py` after the `format_time` function (around line 53):
|
||||
|
||||
```python
|
||||
def resolve_keyframe(
|
||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||
t: float,
|
||||
tolerance: float = 0.05,
|
||||
) -> tuple[float, float, str | None, bool, bool] | None:
|
||||
"""Return the latest keyframe at or before *t*, or None."""
|
||||
result = None
|
||||
for kf in keyframes:
|
||||
if kf[0] <= t + tolerance:
|
||||
result = kf
|
||||
else:
|
||||
break
|
||||
return result
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
|
||||
Expected: 6 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: add resolve_keyframe helper for widened keyframe tuples"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Widen keyframe tuple and update storage
|
||||
|
||||
Change `_crop_keyframes` from `list[tuple[float, float]]` to `list[tuple[float, float, str | None, bool, bool]]` in both `TimelineWidget` and `MainWindow`. Update `set_crop_keyframes` signature.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:735` (TimelineWidget._crop_keyframes)
|
||||
- Modify: `main.py:786-787` (TimelineWidget.set_crop_keyframes)
|
||||
- Modify: `main.py:1755` (MainWindow._crop_keyframes)
|
||||
|
||||
**Step 1: Update TimelineWidget**
|
||||
|
||||
At line 735, change:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||||
```
|
||||
|
||||
At lines 786-787, change:
|
||||
```python
|
||||
def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None:
|
||||
self._crop_keyframes = kfs
|
||||
```
|
||||
to:
|
||||
```python
|
||||
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
|
||||
self._crop_keyframes = kfs
|
||||
```
|
||||
|
||||
**Step 2: Update MainWindow**
|
||||
|
||||
At line 1755, change:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
||||
```
|
||||
|
||||
**Step 3: Run existing tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 46 + 6 new = 52 PASS (no existing tests touch keyframes directly)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "refactor: widen keyframe tuple to carry ratio and random flags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update keyframe creation to snapshot crop state
|
||||
|
||||
When the user clicks the crop bar in lock mode, snapshot the current ratio, rand_portrait, and rand_square into the keyframe.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2519-2538` (_on_crop_click lock-mode branch)
|
||||
|
||||
**Step 1: Update the keyframe creation code**
|
||||
|
||||
At lines 2525-2532, change:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
(t, c) for t, c in self._crop_keyframes
|
||||
if abs(t - play_t) > 0.05
|
||||
]
|
||||
self._crop_keyframes.append((play_t, frac))
|
||||
self._crop_keyframes.sort()
|
||||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ({len(self._crop_keyframes)} total)")
|
||||
```
|
||||
to:
|
||||
```python
|
||||
ratio_text = self._cmb_portrait.currentText()
|
||||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||||
kf_rand_s = self._chk_rand_square.isChecked()
|
||||
self._crop_keyframes = [
|
||||
kf for kf in self._crop_keyframes
|
||||
if abs(kf[0] - play_t) > 0.05
|
||||
]
|
||||
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
|
||||
self._crop_keyframes.sort()
|
||||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
|
||||
```
|
||||
|
||||
**Step 2: Update keyframe deletion filter**
|
||||
|
||||
At lines 2356-2357, change:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
(t, c) for t, c in self._crop_keyframes
|
||||
if abs(t - time) > 0.05
|
||||
]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
kf for kf in self._crop_keyframes
|
||||
if abs(kf[0] - time) > 0.05
|
||||
]
|
||||
```
|
||||
|
||||
**Step 3: Run existing tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 52 PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: snapshot ratio and random flags into crop keyframes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update export to apply full keyframe state
|
||||
|
||||
Replace the keyframe application loop and random crop logic in `_on_export` to use the new fields.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2754-2782` (keyframe application + random crop logic in _on_export)
|
||||
- Test: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Write a test for the export keyframe resolution logic**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
from main import apply_keyframes_to_jobs
|
||||
|
||||
def test_apply_keyframes_no_keyframes():
|
||||
jobs = [(0.0, "/out/a", None, 0.5), (3.0, "/out/b", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, [], base_center=0.5, base_ratio=None,
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
# No keyframes: jobs get base values; rand flags come from base
|
||||
assert result == [
|
||||
(0.0, "/out/a", None, 0.5, True, False),
|
||||
(3.0, "/out/b", None, 0.5, True, False),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_with_keyframes():
|
||||
kfs = [
|
||||
(0.0, 0.3, "9:16", True, False),
|
||||
(4.0, 0.7, None, False, True),
|
||||
]
|
||||
jobs = [
|
||||
(0.0, "/out/a", None, 0.5),
|
||||
(3.0, "/out/b", None, 0.5),
|
||||
(6.0, "/out/c", None, 0.5),
|
||||
]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio=None,
|
||||
base_rand_p=False, base_rand_s=False)
|
||||
assert result == [
|
||||
(0.0, "/out/a", "9:16", 0.3, True, False),
|
||||
(3.0, "/out/b", "9:16", 0.3, True, False),
|
||||
(6.0, "/out/c", None, 0.7, False, True),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_before_first_uses_base():
|
||||
kfs = [(5.0, 0.8, "1:1", False, True)]
|
||||
jobs = [(1.0, "/out/a", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
|
||||
Expected: FAIL (ImportError)
|
||||
|
||||
**Step 3: Write the apply_keyframes_to_jobs function**
|
||||
|
||||
Add to `main.py` after `resolve_keyframe`:
|
||||
|
||||
```python
|
||||
def apply_keyframes_to_jobs(
|
||||
jobs: list[tuple[float, str, str | None, float]],
|
||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||
base_center: float,
|
||||
base_ratio: str | None,
|
||||
base_rand_p: bool,
|
||||
base_rand_s: bool,
|
||||
) -> list[tuple[float, str, str | None, float, bool, bool]]:
|
||||
"""Resolve each job's crop state from keyframes, returning widened tuples.
|
||||
|
||||
Returns list of (start, path, ratio, center, rand_portrait, rand_square).
|
||||
"""
|
||||
result = []
|
||||
for s, o, _r, _c in jobs:
|
||||
kf = resolve_keyframe(keyframes, s)
|
||||
if kf is not None:
|
||||
_, center, ratio, rp, rs = kf
|
||||
else:
|
||||
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
|
||||
result.append((s, o, ratio, center, rp, rs))
|
||||
return result
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
|
||||
Expected: 3 PASS
|
||||
|
||||
**Step 5: Update _on_export to use the new functions**
|
||||
|
||||
Replace lines 2754-2782 (the keyframe application block + random crop block) with:
|
||||
|
||||
```python
|
||||
# Apply crop keyframes (or fall back to base state).
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
|
||||
# Random crop: for each clip whose effective flags are set,
|
||||
# ~1 in 3 gets a random ratio applied.
|
||||
final_jobs = []
|
||||
# Collect indices eligible for random crop, grouped by flag combo.
|
||||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||||
rand_indices: set[int] = set()
|
||||
if portrait_eligible and n_clips > 1:
|
||||
n = max(1, len(portrait_eligible) // 3)
|
||||
rand_indices.update(random.sample(portrait_eligible, min(n, len(portrait_eligible))))
|
||||
if square_eligible and n_clips > 1:
|
||||
n = max(1, len(square_eligible) // 3)
|
||||
rand_indices.update(random.sample(square_eligible, min(n, len(square_eligible))))
|
||||
|
||||
for i, (s, o, ratio, center, rp, rs) in enumerate(widened):
|
||||
if i in rand_indices:
|
||||
pool = []
|
||||
if rp:
|
||||
pool.append("9:16")
|
||||
if rs:
|
||||
pool.append("1:1")
|
||||
if pool:
|
||||
ratio = random.choice(pool)
|
||||
jobs.append((s, o, ratio, center))
|
||||
|
||||
# Replace jobs with the resolved list.
|
||||
jobs = jobs[n_clips:] # drop the original entries, keep the new ones
|
||||
```
|
||||
|
||||
Note: `jobs` was built with `n_clips` entries in the loop above. We append resolved entries and then slice off the originals.
|
||||
|
||||
Actually, a cleaner rewrite of the tail — replace the entire block from the keyframe comment through the random crop block with:
|
||||
|
||||
```python
|
||||
# Apply crop keyframes (or fall back to base state).
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
|
||||
# Random crop: eligible clips (per their keyframe flags) have
|
||||
# ~1 in 3 chance of getting a random ratio applied.
|
||||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||||
rand_indices: dict[int, list[str]] = {}
|
||||
if portrait_eligible and n_clips > 1:
|
||||
n = max(1, len(portrait_eligible) // 3)
|
||||
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
||||
rand_indices.setdefault(i, []).append("9:16")
|
||||
if square_eligible and n_clips > 1:
|
||||
n = max(1, len(square_eligible) // 3)
|
||||
for i in random.sample(square_eligible, min(n, len(square_eligible))):
|
||||
rand_indices.setdefault(i, []).append("1:1")
|
||||
|
||||
jobs = []
|
||||
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
||||
if i in rand_indices:
|
||||
ratio = random.choice(rand_indices[i])
|
||||
jobs.append((s, o, ratio, center))
|
||||
```
|
||||
|
||||
**Step 6: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: apply keyframe crop modes during export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update diamond rendering with color coding
|
||||
|
||||
Color-code timeline keyframe diamonds based on their random flags.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:898-910` (TimelineWidget.paintEvent keyframe diamond section)
|
||||
|
||||
**Step 1: Replace the diamond rendering block**
|
||||
|
||||
Replace lines 898-910:
|
||||
```python
|
||||
# ── crop keyframe diamonds ────────────────────────────────────
|
||||
if self._crop_keyframes and self._duration > 0:
|
||||
for (kt, _kc) in self._crop_keyframes:
|
||||
kx = int(kt / self._duration * w)
|
||||
d = 4 # half-size of diamond
|
||||
ky = h - d - 2 # near bottom of track
|
||||
diamond = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||||
])
|
||||
p.setBrush(QColor(255, 180, 0))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawPolygon(diamond)
|
||||
```
|
||||
|
||||
with:
|
||||
```python
|
||||
# ── crop keyframe diamonds ────────────────────────────────────
|
||||
if self._crop_keyframes and self._duration > 0:
|
||||
_KF_GOLD = QColor(255, 180, 0)
|
||||
_KF_RED = QColor(220, 60, 60)
|
||||
_KF_BLUE = QColor(60, 180, 220)
|
||||
for kf in self._crop_keyframes:
|
||||
kt = kf[0]
|
||||
rp = kf[3] if len(kf) > 3 else False
|
||||
rs = kf[4] if len(kf) > 4 else False
|
||||
kx = int(kt / self._duration * w)
|
||||
d = 4 # half-size of diamond
|
||||
ky = h - d - 2 # near bottom of track
|
||||
if rp and rs:
|
||||
# Split diamond: left half red, right half blue
|
||||
left = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx, ky + d),
|
||||
QPoint(kx - d, ky),
|
||||
])
|
||||
right = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d),
|
||||
])
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(_KF_RED)
|
||||
p.drawPolygon(left)
|
||||
p.setBrush(_KF_BLUE)
|
||||
p.drawPolygon(right)
|
||||
else:
|
||||
diamond = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||||
])
|
||||
if rp:
|
||||
color = _KF_RED
|
||||
elif rs:
|
||||
color = _KF_BLUE
|
||||
else:
|
||||
color = _KF_GOLD
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawPolygon(diamond)
|
||||
```
|
||||
|
||||
**Step 2: Update the context menu keyframe hit detection**
|
||||
|
||||
At line 980, change:
|
||||
```python
|
||||
for (kt, _kc) in self._crop_keyframes:
|
||||
```
|
||||
to:
|
||||
```python
|
||||
for kf in self._crop_keyframes:
|
||||
kt = kf[0]
|
||||
```
|
||||
|
||||
And remove the `_kc` reference — use `kf[0]` for `kt` only. The rest of the hit-detection logic stays the same.
|
||||
|
||||
**Step 3: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 4: Manual test**
|
||||
|
||||
Launch the app, load a video, enable lock mode, set keyframes with different combinations of random portrait/square. Verify:
|
||||
- Gold diamond when no random flags set
|
||||
- Red diamond when only portrait
|
||||
- Blue diamond when only square
|
||||
- Split red/blue when both
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: color-code keyframe diamonds by crop mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update lock-mode scrub preview
|
||||
|
||||
When scrubbing in lock mode, update the crop bar, overlay, and (visually) the random checkboxes to reflect the effective keyframe state at the playback position.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2605-2621` (_on_seek_changed)
|
||||
|
||||
**Step 1: Replace the keyframe preview block**
|
||||
|
||||
Replace lines 2610-2621:
|
||||
```python
|
||||
if self._crop_keyframes:
|
||||
center = self._crop_center
|
||||
for kt, kc in self._crop_keyframes:
|
||||
if kt <= t + 0.05:
|
||||
center = kc
|
||||
else:
|
||||
break
|
||||
self._crop_bar.set_crop_center(center)
|
||||
ratio = self._cmb_portrait.currentText()
|
||||
if ratio != "Off":
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||||
```
|
||||
|
||||
with:
|
||||
```python
|
||||
if self._crop_keyframes:
|
||||
kf = resolve_keyframe(self._crop_keyframes, t)
|
||||
if kf is not None:
|
||||
_, center, ratio, rp, rs = kf
|
||||
self._crop_bar.set_crop_center(center)
|
||||
if ratio is not None:
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||||
else:
|
||||
self._update_rand_overlays()
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: preview effective keyframe crop state during lock-mode scrub"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update overwrite-mode keyframe application
|
||||
|
||||
The overwrite path (lines 2727-2738) also builds jobs. It doesn't currently apply keyframes, but should for consistency.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2727-2738` (overwrite branch in _on_export)
|
||||
|
||||
**Step 1: Check and update**
|
||||
|
||||
After the overwrite jobs are built, apply the same `apply_keyframes_to_jobs` logic if keyframes exist. The overwrite branch builds `jobs` as `(start, path, base_ratio, base_center)` — same shape as the normal path.
|
||||
|
||||
Add after line 2738 (`self._overwrite_group = []`):
|
||||
|
||||
```python
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
if self._crop_keyframes:
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: apply keyframe crop modes in overwrite exports too"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update import in test file and final validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_utils.py:2` (import line)
|
||||
|
||||
**Step 1: Update imports**
|
||||
|
||||
At line 2, add the new functions to the import:
|
||||
```python
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation, resolve_keyframe, apply_keyframes_to_jobs
|
||||
```
|
||||
|
||||
(This should already be done incrementally in Tasks 1 and 4, but verify it's correct.)
|
||||
|
||||
**Step 2: Run full test suite**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 55 tests PASS (46 original + 6 resolve_keyframe + 3 apply_keyframes)
|
||||
|
||||
**Step 3: Manual integration test**
|
||||
|
||||
1. Launch `python main.py`, load a video
|
||||
2. Enable lock mode (G or click lock button)
|
||||
3. Scrub to a position, enable "1 random portrait", click crop bar → red diamond appears
|
||||
4. Scrub forward, disable portrait, enable "1 random square", click crop bar → blue diamond appears
|
||||
5. Scrub forward, enable both, click crop bar → split red/blue diamond
|
||||
6. Set clip count to 6+, spread to 2s, export
|
||||
7. Verify that sub-clips falling in each keyframe region get the correct random crop behavior
|
||||
8. Right-click a diamond to delete it — verify it disappears
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_utils.py
|
||||
git commit -m "test: verify imports for keyframe crop mode helpers"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
PyQt6>=6.4
|
||||
python-mpv>=1.0
|
||||
pytest>=7.0
|
||||
ultralytics>=8.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 8-cut Windows setup script
|
||||
# Run once: powershell -ExecutionPolicy Bypass -File setup-windows.ps1
|
||||
#
|
||||
# Prerequisites: Python 3.10+ must be installed and on PATH
|
||||
# https://www.python.org/downloads/
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
Write-Host "=== 8-cut Windows Setup ===" -ForegroundColor Cyan
|
||||
|
||||
# ── Python deps ────────────────────────────────────────────
|
||||
Write-Host "`nInstalling Python dependencies..."
|
||||
pip install PyQt6 python-mpv
|
||||
|
||||
# ── libmpv ─────────────────────────────────────────────────
|
||||
$mpvDll = Join-Path $root "libmpv-2.dll"
|
||||
if (Test-Path $mpvDll) {
|
||||
Write-Host "`nlibmpv-2.dll already present, skipping." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`nDownloading libmpv..."
|
||||
$release = Invoke-RestMethod "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
|
||||
$asset = $release.assets | Where-Object { $_.name -like "mpv-dev-x86_64-v3-*" } | Select-Object -First 1
|
||||
$tmpFile = Join-Path $root "mpv-dev.7z"
|
||||
Invoke-WebRequest $asset.browser_download_url -OutFile $tmpFile
|
||||
7z x $tmpFile -o"$root\mpv-dev" -y | Out-Null
|
||||
Copy-Item "$root\mpv-dev\libmpv-2.dll" $root
|
||||
Remove-Item $tmpFile -Force
|
||||
Remove-Item "$root\mpv-dev" -Recurse -Force
|
||||
Write-Host "libmpv-2.dll downloaded." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ── ffmpeg ─────────────────────────────────────────────────
|
||||
$ffmpeg = Join-Path $root "ffmpeg.exe"
|
||||
if (Test-Path $ffmpeg) {
|
||||
Write-Host "`nffmpeg.exe already present, skipping." -ForegroundColor Green
|
||||
} else {
|
||||
# Check if ffmpeg is on PATH
|
||||
$onPath = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if ($onPath) {
|
||||
Write-Host "`nffmpeg found on PATH: $($onPath.Source)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`nDownloading ffmpeg..."
|
||||
$ffUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
$tmpZip = Join-Path $root "ffmpeg.zip"
|
||||
Invoke-WebRequest $ffUrl -OutFile $tmpZip
|
||||
Expand-Archive $tmpZip -DestinationPath "$root\ffmpeg-tmp" -Force
|
||||
$bin = Get-ChildItem -Path "$root\ffmpeg-tmp" -Recurse -Filter ffmpeg.exe | Select-Object -First 1
|
||||
Copy-Item "$($bin.DirectoryName)\ffmpeg.exe" $root
|
||||
Copy-Item "$($bin.DirectoryName)\ffprobe.exe" $root
|
||||
Remove-Item $tmpZip -Force
|
||||
Remove-Item "$root\ffmpeg-tmp" -Recurse -Force
|
||||
Write-Host "ffmpeg.exe downloaded." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n=== Setup complete ===" -ForegroundColor Cyan
|
||||
Write-Host "Run 8-cut with: python main.py"
|
||||
Write-Host "Or double-click: 8cut.bat"
|
||||
+244
-84
@@ -1,16 +1,24 @@
|
||||
import tempfile, os
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_tsv_path, append_to_tsv
|
||||
from main import _normalize_filename, ProcessedDB
|
||||
import tempfile, os, json
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation, resolve_keyframe, apply_keyframes_to_jobs
|
||||
from main import ProcessedDB
|
||||
|
||||
|
||||
def test_build_export_path_first():
|
||||
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
|
||||
assert build_export_path("/out", "clip", 1) == "/out/clip_001/clip_001.mp4"
|
||||
|
||||
def test_build_export_path_counter():
|
||||
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
|
||||
assert build_export_path("/out", "clip", 42) == "/out/clip_042/clip_042.mp4"
|
||||
|
||||
def test_build_export_path_deep_counter():
|
||||
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
|
||||
assert build_export_path("/out", "shot", 999) == "/out/shot_999/shot_999.mp4"
|
||||
|
||||
def test_build_export_path_sub():
|
||||
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0.mp4"
|
||||
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001/clip_001_2.mp4"
|
||||
|
||||
def test_build_sequence_dir_sub():
|
||||
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0"
|
||||
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001/clip_001_1"
|
||||
|
||||
def test_format_time_seconds():
|
||||
assert format_time(0.0) == "0:00.0"
|
||||
@@ -45,63 +53,47 @@ def test_ffmpeg_command_with_resize():
|
||||
assert cmd[-1] == "/out/clip_001.mp4"
|
||||
|
||||
|
||||
# --- _normalize_filename ---
|
||||
|
||||
def test_normalize_strips_extension():
|
||||
assert _normalize_filename("clip.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_resolution():
|
||||
assert _normalize_filename("clip_2160p.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_1080p():
|
||||
assert _normalize_filename("clip_1080p.mkv") == "clip"
|
||||
|
||||
def test_normalize_strips_multiple_tags():
|
||||
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
|
||||
|
||||
def test_normalize_lowercases():
|
||||
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
|
||||
|
||||
def test_normalize_collapses_separators():
|
||||
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
|
||||
|
||||
|
||||
# --- ProcessedDB ---
|
||||
|
||||
def test_db_add_and_find_exact():
|
||||
def test_db_add_and_get_markers():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
|
||||
assert db.find_similar("video.mp4") == "video.mp4"
|
||||
markers = db.get_markers("video.mp4")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 12.5
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_resolution_variant():
|
||||
def test_db_exact_match_only():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
|
||||
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
|
||||
# Different filename — no match even if similar
|
||||
assert db.get_markers("episode_s01e01_1080p.mkv") == []
|
||||
# Exact filename — match
|
||||
assert len(db.get_markers("episode_s01e01_2160p.mkv")) == 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_no_match():
|
||||
def test_db_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
|
||||
assert db.find_similar("completely_different_zzzz.mp4") is None
|
||||
assert db.get_markers("completely_different.mp4") == []
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_disabled_survives_bad_path():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
|
||||
assert db.find_similar("x.mp4") is None
|
||||
assert db.get_markers("x.mp4") == []
|
||||
|
||||
def test_db_get_markers_returns_sorted():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
@@ -119,19 +111,6 @@ def test_db_get_markers_returns_sorted():
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_fuzzy_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
|
||||
markers = db.get_markers("show_1080p.mkv")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 5.0
|
||||
assert markers[0][2] == "/out/s_001.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
@@ -174,16 +153,6 @@ def test_ffmpeg_command_portrait_off():
|
||||
cmd = build_ffmpeg_command("/in/video.mp4", 0.0, "/out/clip.mp4")
|
||||
assert "-vf" not in cmd
|
||||
|
||||
def test_mask_output_dir_basic():
|
||||
assert build_mask_output_dir("/out/clip_001.mp4") == "/out/clip_001_masks"
|
||||
|
||||
def test_mask_output_dir_mkv():
|
||||
assert build_mask_output_dir("/out/my_clip.mkv") == "/out/my_clip_masks"
|
||||
|
||||
def test_mask_output_dir_nested():
|
||||
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
|
||||
|
||||
|
||||
# --- build_audio_extract_command ---
|
||||
|
||||
def test_audio_extract_output_path():
|
||||
@@ -208,19 +177,16 @@ def test_audio_extract_timing():
|
||||
|
||||
|
||||
def test_build_sequence_dir_basic():
|
||||
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
|
||||
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001/clip_001"
|
||||
|
||||
def test_build_sequence_dir_counter():
|
||||
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042"
|
||||
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042/clip_042"
|
||||
|
||||
def test_ffmpeg_command_image_sequence():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
|
||||
assert "-c:v" in cmd
|
||||
assert cmd[cmd.index("-c:v") + 1] == "libwebp"
|
||||
assert "-lossless" in cmd
|
||||
assert cmd[cmd.index("-lossless") + 1] == "1"
|
||||
assert "-compression_level" in cmd
|
||||
assert cmd[cmd.index("-compression_level") + 1] == "4"
|
||||
assert "-quality" in cmd
|
||||
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
|
||||
|
||||
def test_ffmpeg_command_image_sequence_with_resize():
|
||||
@@ -237,28 +203,47 @@ def test_ffmpeg_command_image_sequence_no_audio():
|
||||
assert "aac" not in cmd
|
||||
|
||||
|
||||
def test_annotation_tsv_path():
|
||||
assert build_annotation_tsv_path("/out") == "/out/dataset.tsv"
|
||||
def test_annotation_json_path():
|
||||
assert build_annotation_json_path("/out") == "/out/dataset.json"
|
||||
|
||||
def test_append_to_tsv_creates_file():
|
||||
def test_upsert_creates_file():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
append_to_tsv(d, "clip_001", "dog barking")
|
||||
with open(os.path.join(d, "dataset.tsv")) as f:
|
||||
lines = f.readlines()
|
||||
assert lines == ["clip_001\tdog barking\n"]
|
||||
clip = os.path.join(d, "clip_001.mp4")
|
||||
upsert_clip_annotation(d, clip, "dog barking")
|
||||
with open(os.path.join(d, "dataset.json")) as f:
|
||||
entries = json.load(f)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["label"] == "dog barking"
|
||||
assert entries[0]["path"] == clip
|
||||
|
||||
def test_append_to_tsv_appends():
|
||||
def test_upsert_appends_new_clips():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
append_to_tsv(d, "clip_001", "dog barking")
|
||||
append_to_tsv(d, "clip_002", "cat meowing")
|
||||
with open(os.path.join(d, "dataset.tsv")) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 2
|
||||
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "dog barking")
|
||||
upsert_clip_annotation(d, os.path.join(d, "clip_002.mp4"), "cat meowing")
|
||||
with open(os.path.join(d, "dataset.json")) as f:
|
||||
entries = json.load(f)
|
||||
assert len(entries) == 2
|
||||
|
||||
def test_append_to_tsv_empty_label_skips():
|
||||
def test_upsert_replaces_existing():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
append_to_tsv(d, "clip_001", "")
|
||||
assert not os.path.exists(os.path.join(d, "dataset.tsv"))
|
||||
clip = os.path.join(d, "clip_001.mp4")
|
||||
upsert_clip_annotation(d, clip, "dog barking")
|
||||
upsert_clip_annotation(d, clip, "cat meowing")
|
||||
with open(os.path.join(d, "dataset.json")) as f:
|
||||
entries = json.load(f)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["label"] == "cat meowing"
|
||||
|
||||
def test_upsert_empty_label_skips():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "")
|
||||
assert not os.path.exists(os.path.join(d, "dataset.json"))
|
||||
|
||||
def test_upsert_missing_folder_creates_it():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
nested = os.path.join(d, "subdir", "deep")
|
||||
upsert_clip_annotation(nested, os.path.join(nested, "clip_001.mp4"), "dog barking")
|
||||
assert os.path.exists(os.path.join(nested, "dataset.json"))
|
||||
|
||||
def test_db_stores_label_and_category():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
@@ -273,8 +258,183 @@ def test_db_stores_label_and_category():
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_append_to_tsv_missing_folder_creates_it():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
nested = os.path.join(d, "subdir", "deep")
|
||||
append_to_tsv(nested, "clip_001", "dog barking")
|
||||
assert os.path.exists(os.path.join(nested, "dataset.tsv"))
|
||||
|
||||
def test_db_get_group_returns_all_sub_clips():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_2.mp4")
|
||||
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
||||
assert len(group) == 3
|
||||
assert "/out/clip_001/clip_001_0.mp4" in group
|
||||
assert "/out/clip_001/clip_001_2.mp4" in group
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_get_group_isolates_by_start_time():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
||||
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
||||
assert len(group) == 2
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_delete_group_removes_all():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
||||
deleted = db.delete_group("/out/clip_001/clip_001_0.mp4")
|
||||
assert len(deleted) == 2
|
||||
# clip_002 should still exist
|
||||
markers = db.get_markers("video.mp4")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 30.0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_get_group_disabled():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
assert db.get_group("/out/clip_001.mp4") == []
|
||||
|
||||
|
||||
def test_db_delete_group_disabled():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
assert db.delete_group("/out/clip_001.mp4") == []
|
||||
|
||||
|
||||
# --- Profiles ---
|
||||
|
||||
def test_db_markers_isolated_by_profile():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 10.0, "/out/a_001.mp4", profile="landscape")
|
||||
db.add("video.mp4", 20.0, "/out/b_001.mp4", profile="portrait")
|
||||
land = db.get_markers("video.mp4", profile="landscape")
|
||||
port = db.get_markers("video.mp4", profile="portrait")
|
||||
assert len(land) == 1
|
||||
assert land[0][0] == 10.0
|
||||
assert len(port) == 1
|
||||
assert port[0][0] == 20.0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_get_profiles():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
assert db.get_profiles() == []
|
||||
db.add("a.mp4", 0.0, "/out/a.mp4", profile="beta")
|
||||
db.add("b.mp4", 0.0, "/out/b.mp4", profile="alpha")
|
||||
db.add("c.mp4", 0.0, "/out/c.mp4", profile="beta")
|
||||
profiles = db.get_profiles()
|
||||
assert profiles == ["alpha", "beta"]
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_get_profiles_disabled():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
assert db.get_profiles() == []
|
||||
|
||||
|
||||
def test_db_default_profile_backward_compat():
|
||||
"""Existing tests pass without explicit profile — defaults to 'default'."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 5.0, "/out/clip.mp4")
|
||||
markers = db.get_markers("video.mp4") # no profile arg
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 5.0
|
||||
assert db.get_profiles() == ["default"]
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# --- resolve_keyframe ---
|
||||
|
||||
def test_resolve_keyframe_empty():
|
||||
assert resolve_keyframe([], 5.0) is None
|
||||
|
||||
def test_resolve_keyframe_before_first():
|
||||
kfs = [(3.0, 0.5, None, False, False)]
|
||||
assert resolve_keyframe(kfs, 1.0) is None
|
||||
|
||||
def test_resolve_keyframe_exact():
|
||||
kfs = [(2.0, 0.3, "9:16", True, False)]
|
||||
assert resolve_keyframe(kfs, 2.0) == (2.0, 0.3, "9:16", True, False)
|
||||
|
||||
def test_resolve_keyframe_between():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 3.0) == (1.0, 0.2, None, False, False)
|
||||
|
||||
def test_resolve_keyframe_after_last():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 10.0) == (5.0, 0.8, "1:1", False, True)
|
||||
|
||||
def test_resolve_keyframe_tolerance():
|
||||
kfs = [(4.0, 0.5, None, True, True)]
|
||||
assert resolve_keyframe(kfs, 3.96) == (4.0, 0.5, None, True, True)
|
||||
|
||||
|
||||
# --- apply_keyframes_to_jobs ---
|
||||
|
||||
def test_apply_keyframes_no_keyframes():
|
||||
jobs = [(0.0, "/out/a", None, 0.5), (3.0, "/out/b", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, [], base_center=0.5, base_ratio=None,
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
assert result == [
|
||||
(0.0, "/out/a", None, 0.5, True, False),
|
||||
(3.0, "/out/b", None, 0.5, True, False),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_with_keyframes():
|
||||
kfs = [
|
||||
(0.0, 0.3, "9:16", True, False),
|
||||
(4.0, 0.7, None, False, True),
|
||||
]
|
||||
jobs = [
|
||||
(0.0, "/out/a", None, 0.5),
|
||||
(3.0, "/out/b", None, 0.5),
|
||||
(6.0, "/out/c", None, 0.5),
|
||||
]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio=None,
|
||||
base_rand_p=False, base_rand_s=False)
|
||||
assert result == [
|
||||
(0.0, "/out/a", "9:16", 0.3, True, False),
|
||||
(3.0, "/out/b", "9:16", 0.3, True, False),
|
||||
(6.0, "/out/c", None, 0.7, False, True),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_before_first_uses_base():
|
||||
kfs = [(5.0, 0.8, "1:1", False, True)]
|
||||
jobs = [(1.0, "/out/a", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Depth Anything V2 mask generation script.
|
||||
|
||||
Usage:
|
||||
python tools/depth_masks.py --input video.mp4 --output masks_dir/
|
||||
|
||||
Outputs one binary PNG per frame: frame_0000.png, frame_0001.png, …
|
||||
Foreground = white (255), background = black (0), via Otsu threshold on depth map.
|
||||
Requires: torch, transformers, opencv-python, Pillow
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from transformers import pipeline
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
import torch
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
print(f"Using device: {device}", flush=True)
|
||||
|
||||
pipe = pipeline(
|
||||
"depth-estimation",
|
||||
model="depth-anything/Depth-Anything-V2-Large-hf",
|
||||
device=device,
|
||||
)
|
||||
|
||||
cap = cv2.VideoCapture(args.input)
|
||||
if not cap.isOpened():
|
||||
print(f"ERROR: cannot open {args.input}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
idx = 0
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
pil_img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
||||
result = pipe(pil_img)
|
||||
depth = np.array(result["depth"]) # float32 array
|
||||
|
||||
# Normalise to 0–255
|
||||
d_min, d_max = depth.min(), depth.max()
|
||||
if d_max > d_min:
|
||||
depth_u8 = ((depth - d_min) / (d_max - d_min) * 255).astype(np.uint8)
|
||||
else:
|
||||
depth_u8 = np.zeros_like(depth, dtype=np.uint8)
|
||||
|
||||
# Otsu threshold: closer objects (higher depth value) = foreground
|
||||
_, mask = cv2.threshold(depth_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
|
||||
out_path = os.path.join(args.output, f"frame_{idx:04d}.png")
|
||||
cv2.imwrite(out_path, mask)
|
||||
|
||||
idx += 1
|
||||
print(f"frame {idx}/{total}", flush=True)
|
||||
|
||||
cap.release()
|
||||
print("done", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot migration: generate dataset.json files from ~/.8cut.db history.
|
||||
|
||||
For each export folder that has records in the database, writes (or merges
|
||||
into) a dataset.json with path (relative to the folder) and label per clip.
|
||||
|
||||
Usage:
|
||||
python tools/migrate_dataset_json.py [--dry-run]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DB_PATH = Path.home() / ".8cut.db"
|
||||
|
||||
|
||||
def load_db_records(db_path: Path) -> list[dict]:
|
||||
con = sqlite3.connect(db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
rows = con.execute(
|
||||
"SELECT output_path, label FROM processed WHERE label != ''"
|
||||
).fetchall()
|
||||
con.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def build_entries_for_folder(folder: str, records: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{"path": os.path.abspath(rec["output_path"]), "label": rec["label"]}
|
||||
for rec in records
|
||||
]
|
||||
|
||||
|
||||
def merge_into_json(json_path: str, new_entries: list[dict], dry_run: bool) -> int:
|
||||
"""Merge new_entries into existing json_path, returning count added/updated."""
|
||||
existing: list[dict] = []
|
||||
if os.path.exists(json_path):
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
existing = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
existing = []
|
||||
|
||||
by_path = {e["path"]: e for e in existing}
|
||||
changed = 0
|
||||
for entry in new_entries:
|
||||
if by_path.get(entry["path"]) != entry:
|
||||
by_path[entry["path"]] = entry
|
||||
changed += 1
|
||||
|
||||
merged = list(by_path.values())
|
||||
if not dry_run and changed:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be written without writing")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not DB_PATH.exists():
|
||||
print(f"Database not found: {DB_PATH}")
|
||||
return
|
||||
|
||||
records = load_db_records(DB_PATH)
|
||||
if not records:
|
||||
print("No labelled records in database.")
|
||||
return
|
||||
|
||||
# Group records by parent folder of output_path
|
||||
by_folder: dict[str, list[dict]] = defaultdict(list)
|
||||
for rec in records:
|
||||
folder = os.path.dirname(rec["output_path"])
|
||||
by_folder[folder].append(rec)
|
||||
|
||||
total_written = 0
|
||||
for folder, recs in sorted(by_folder.items()):
|
||||
entries = build_entries_for_folder(folder, recs)
|
||||
json_path = os.path.join(folder, "dataset.json")
|
||||
changed = merge_into_json(json_path, entries, args.dry_run)
|
||||
status = "(dry run) " if args.dry_run else ""
|
||||
print(f"{status}{json_path}: {len(entries)} clips, {changed} added/updated")
|
||||
total_written += changed
|
||||
|
||||
suffix = " (dry run)" if args.dry_run else ""
|
||||
print(f"\nTotal: {total_written} entries written{suffix}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""SAM2 mask generation script.
|
||||
|
||||
Usage:
|
||||
python tools/sam_masks.py --input video.mp4 --output masks_dir/
|
||||
|
||||
Outputs one binary PNG per frame: frame_0000.png, frame_0001.png, …
|
||||
Uses center of first frame as positive point prompt, propagates across all frames.
|
||||
Requires: torch, segment-anything-2, opencv-python
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
import torch
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
print(f"Using device: {device}", flush=True)
|
||||
|
||||
# Extract frames to temp directory (SAM2 video predictor needs image files)
|
||||
with tempfile.TemporaryDirectory() as frame_dir:
|
||||
cap = cv2.VideoCapture(args.input)
|
||||
if not cap.isOpened():
|
||||
print(f"ERROR: cannot open {args.input}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
idx = 0
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
cv2.imwrite(os.path.join(frame_dir, f"{idx:04d}.jpg"), frame)
|
||||
idx += 1
|
||||
cap.release()
|
||||
|
||||
print(f"Extracted {idx} frames", flush=True)
|
||||
|
||||
# SAM2: use from_pretrained (SAM2.1+ / HuggingFace integration)
|
||||
from sam2.sam2_video_predictor import SAM2VideoPredictor
|
||||
|
||||
predictor = SAM2VideoPredictor.from_pretrained(
|
||||
"facebook/sam2-hiera-large"
|
||||
).to(device)
|
||||
|
||||
with torch.inference_mode():
|
||||
state = predictor.init_state(video_path=frame_dir)
|
||||
|
||||
# Center of first frame as positive point prompt
|
||||
cx, cy = width // 2, height // 2
|
||||
_, _, _ = predictor.add_new_points_or_box(
|
||||
inference_state=state,
|
||||
frame_idx=0,
|
||||
obj_id=1,
|
||||
points=np.array([[cx, cy]], dtype=np.float32),
|
||||
labels=np.array([1], dtype=np.int32),
|
||||
)
|
||||
|
||||
for frame_idx, obj_ids, out_mask_logits in predictor.propagate_in_video(state):
|
||||
# out_mask_logits: (N_objects, 1, H, W) — threshold logits at 0
|
||||
mask = (out_mask_logits[0].squeeze().cpu().numpy() > 0.0).astype(np.uint8) * 255
|
||||
out_path = os.path.join(args.output, f"frame_{frame_idx:04d}.png")
|
||||
cv2.imwrite(out_path, mask)
|
||||
print(f"frame {frame_idx + 1}/{total}", flush=True)
|
||||
|
||||
print("done", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user