31 Commits

Author SHA1 Message Date
Ethanfel c5dd2d00a0 fix: use noarchive mode and enable console to debug PYZ TOC error
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Bypasses PYZ archive entirely — modules stored as individual .pyc files.
Console enabled temporarily to capture error output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:12:58 +02:00
Ethanfel 34d8ad1dc7 feat: add Windows setup script and launcher for running from source
- setup-windows.ps1: downloads libmpv DLL and ffmpeg, installs pip deps
- 8cut.bat: double-click launcher
- main.py: add_dll_directory for libmpv next to script (not just frozen)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:08:17 +02:00
Ethanfel 46bd617f0a fix: disable UPX compression to prevent PYZ archive corruption
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
UPX can corrupt Python bytecode in PyInstaller bundles, causing
"PYZ archive entry not found in the TOC" on Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:06:30 +02:00
Ethanfel e8ecfc0525 ci: publish release automatically, re-enable macOS build
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:59:29 +02:00
Ethanfel 198ec68382 ci: temporarily disable macOS job while debugging Windows build
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:49:08 +02:00
Ethanfel 920f724dbd fix: find libmpv asset by name pattern instead of constructing URL
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Asset filenames include a git hash that can't be predicted from the tag
alone. Use the API assets list to find the correct download URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:48:08 +02:00
Ethanfel 94ea4c63ca fix: use GitHub API to fetch latest libmpv tag instead of redirect
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Invoke-WebRequest fails on 302 redirects in newer PowerShell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:45:44 +02:00
Ethanfel 653e4a5e13 refactor: split Windows and macOS into separate jobs
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Release is created if at least one platform succeeds, so a failure
on one doesn't block the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:44:13 +02:00
Ethanfel cd50b3ae0c fix: quote PowerShell path interpolation in ffmpeg copy step
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:43:03 +02:00
Ethanfel 10b77e79f7 fix: drop macos-13 runner (no longer available)
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:42:36 +02:00
Ethanfel 5b4e4bf818 feat: add PyInstaller spec and GitHub Actions release workflow
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-macos-x86_64, macos-13) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Enables cross-platform builds for Windows and macOS. Adds _bin() helper
to resolve bundled ffmpeg in frozen builds, and configures ctypes library
path for bundled libmpv.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:40:44 +02:00
Ethanfel bd4e97c45a fix: lock mode seek falls back to cursor instead of jumping to start
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:15:01 +02:00
Ethanfel 1aeaad7f6d fix: skip keyframe creation at frame 0 where base state applies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:56:48 +02:00
Ethanfel 874632dffa fix: keep export complete message visible until next action
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:52:37 +02:00
Ethanfel 86055f2072 fix: defer preview follow so geometry is up-to-date after main window move
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:51:27 +02:00
Ethanfel 5fddb06354 fix: add right margin to panel, make Hide exported a QPushButton
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:48:51 +02:00
Ethanfel e60263548d feat: move status messages to inline label on settings row
Replace the bottom status bar with a right-aligned label on the
settings row, saving vertical space. Add "Export complete" message
when a batch finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:45:10 +02:00
Ethanfel 86f447f3d6 feat: add Show Hidden button to reveal and unhide playlist files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:39:22 +02:00
Ethanfel 1d5b8023a2 feat: auto-create/remove keyframes when toggling random crop in lock mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:29:11 +02:00
Ethanfel cb4392125d fix: scrub preview fallback before first keyframe + document overwrite behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:08:35 +02:00
Ethanfel 328c800d60 feat: apply keyframe crop modes in overwrite exports too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:05:20 +02:00
Ethanfel 7a35e8268b feat: preview effective keyframe crop state during lock-mode scrub
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:05:08 +02:00
Ethanfel 465894af51 feat: color-code keyframe diamonds by crop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:04:53 +02:00
Ethanfel 1004bd0a28 feat: rewrite export to use per-keyframe crop modes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:58:01 +02:00
Ethanfel 279aee14cb feat: add apply_keyframes_to_jobs helper
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:57:27 +02:00
Ethanfel 4f15f77175 feat: snapshot ratio and random flags into crop keyframes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:52:25 +02:00
Ethanfel 17e42c44b3 refactor: widen keyframe tuple to carry ratio and random flags
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:52:04 +02:00
Ethanfel 8e8c8b9774 feat: add resolve_keyframe helper to extract sorted-keyframe lookup
Adds a pure function that returns the latest keyframe at or before a
given time (with tolerance), replacing the inline lookup pattern that
appears multiple times in main.py. Includes 6 tests covering empty
list, before-first, exact match, between, after-last, and tolerance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:48:00 +02:00
Ethanfel b9e9fa927e chore: add .worktrees/ to .gitignore 2026-04-14 15:45:26 +02:00
Ethanfel 5916b498b1 docs: add keyframe crop modes implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:30:46 +02:00
Ethanfel bda423e8b5 docs: add keyframe crop modes design
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:28:40 +02:00
9 changed files with 1366 additions and 86 deletions
+115
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
*.pyo
.pytest_cache/
.worktrees/
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
python main.py %*
+142
View File
@@ -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,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"
```
+283 -77
View File
@@ -17,16 +17,37 @@ from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog,
)
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
if sys.platform == "win32":
# Help ctypes find libmpv-2.dll next to main.py or in frozen bundle
_dll_dir = Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent
os.add_dll_directory(str(_dll_dir))
elif sys.platform == "darwin" and getattr(sys, "frozen", False):
os.environ.setdefault("DYLD_LIBRARY_PATH", str(Path(sys._MEIPASS)))
import mpv
def _frozen_path() -> Path:
"""Return the directory containing bundled binaries in a PyInstaller build."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS)
return Path(__file__).parent
def _bin(name: str) -> str:
"""Resolve a binary name (e.g. 'ffmpeg') to its full path in frozen builds."""
p = _frozen_path() / name
if p.exists():
return str(p)
return name # fall back to PATH
def _log(*args) -> None:
"""Print a timestamped log line to stderr."""
ts = datetime.now().strftime("%H:%M:%S")
@@ -53,6 +74,44 @@ def format_time(seconds: float) -> str:
return f"{m}:{s:04.1f}"
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
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
def build_ffmpeg_command(
input_path: str, start: float, output_path: str,
short_side: int | None = None,
@@ -65,7 +124,7 @@ def build_ffmpeg_command(
# so there is no keyframe-alignment issue from pre-input seek.
# Image sequences always use libwebp, so skip HW encoder setup.
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence
cmd = ["ffmpeg", "-y"]
cmd = [_bin("ffmpeg"), "-y"]
# VAAPI needs a device for hardware context.
if use_hw_vaapi:
@@ -119,7 +178,7 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
audio_path = sequence_dir + ".wav"
return [
"ffmpeg", "-y",
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
@@ -191,7 +250,7 @@ def detect_hw_encoders() -> list[str]:
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
try:
result = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
[_bin("ffmpeg"), "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
@@ -266,7 +325,7 @@ def extract_frame_cv(video_path: str, time: float):
fd, tmp = tempfile.mkstemp(suffix=".png")
os.close(fd)
try:
cmd = ["ffmpeg", "-y", "-ss", str(time), "-i", video_path,
cmd = [_bin("ffmpeg"), "-y", "-ss", str(time), "-i", video_path,
"-frames:v", "1", tmp]
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode != 0:
@@ -699,7 +758,7 @@ class FrameGrabber(QThread):
def run(self):
try:
cmd = [
"ffmpeg", "-ss", str(self._time),
_bin("ffmpeg"), "-ss", str(self._time),
"-i", self._input,
"-frames:v", "1",
"-f", "image2pipe", "-vcodec", "png",
@@ -732,7 +791,7 @@ class TimelineWidget(QWidget):
self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow
self._play_pos: float | None = None # current playback position (seconds)
self._locked = False # when True, clicks scrub playback, not cursor
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)]
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
@@ -783,7 +842,7 @@ class TimelineWidget(QWidget):
self._play_pos = t
self.update()
def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None:
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
self._crop_keyframes = kfs
self.update()
@@ -897,16 +956,44 @@ class TimelineWidget(QWidget):
# ── crop keyframe diamonds ────────────────────────────────────
if self._crop_keyframes and self._duration > 0:
for (kt, _kc) in self._crop_keyframes:
_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),
])
p.setBrush(QColor(255, 180, 0))
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)
# ── playhead ──────────────────────────────────────────────────
@@ -961,7 +1048,7 @@ class TimelineWidget(QWidget):
def _emit_seek(self):
if self._locked:
self.seek_changed.emit(self._play_pos or 0.0)
self.seek_changed.emit(self._play_pos if self._play_pos is not None else self._cursor)
else:
self.cursor_changed.emit(self._cursor)
@@ -977,7 +1064,8 @@ class TimelineWidget(QWidget):
w = self.width()
# Check keyframe diamonds first.
hit_kf_time = None
for (kt, _kc) in self._crop_keyframes:
for kf in self._crop_keyframes:
kt = kf[0]
kx = kt / self._duration * w
if abs(x - kx) <= 8:
hit_kf_time = kt
@@ -1513,13 +1601,14 @@ class PlaylistWidget(QListWidget):
self._done_counts: dict[str, int] = {} # path → clip count
self._hidden_basenames: set[str] = set()
self._hide_exported = False
self._show_hidden = False
self._visible: list[str] = [] # paths currently shown in widget
self._selected_path: str | None = None
self.itemClicked.connect(self._on_item_clicked)
def _is_visible(self, path: str) -> bool:
if os.path.basename(path) in self._hidden_basenames:
return False
return self._show_hidden
if self._hide_exported and path in self._done_set:
return False
return True
@@ -1531,7 +1620,14 @@ class PlaylistWidget(QListWidget):
self._visible = [p for p in self._paths if self._is_visible(p)]
for path in self._visible:
name = os.path.basename(path)
if path in self._done_set:
is_hidden = os.path.basename(path) in self._hidden_basenames
if is_hidden:
item = QListWidgetItem(f"[hidden] {name}")
item.setForeground(QColor(120, 120, 120))
font = item.font()
font.setItalic(True)
item.setFont(font)
elif path in self._done_set:
n = self._done_counts.get(path, 0)
tag = f"[{n}]" if n else ""
item = QListWidgetItem(f"{tag} {name}")
@@ -1587,6 +1683,10 @@ class PlaylistWidget(QListWidget):
self._hidden_basenames = basenames
self._rebuild()
def set_show_hidden(self, show: bool) -> None:
self._show_hidden = show
self._rebuild()
def set_hide_exported(self, hide: bool) -> None:
self._hide_exported = hide
self._rebuild()
@@ -1645,6 +1745,7 @@ class PlaylistWidget(QListWidget):
self._select(self.row(item))
hide_requested = pyqtSignal(list) # emits list of full paths to hide
unhide_requested = pyqtSignal(list) # emits list of full paths to unhide
def _selected_paths(self) -> list[str]:
return [self._visible[self.row(it)]
@@ -1657,14 +1758,26 @@ class PlaylistWidget(QListWidget):
return
from PyQt6.QtWidgets import QMenu
menu = QMenu(self)
# Check if any selected files are hidden.
hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames]
act_remove = act_hide = act_unhide = None
if len(sel) == 1:
name = os.path.basename(sel[0])
act_remove = menu.addAction(f"Remove: {name}")
if hidden_sel:
act_unhide = menu.addAction(f"Unhide: {name}")
else:
act_hide = menu.addAction(f"Hide in profile: {name}")
else:
act_remove = menu.addAction(f"Remove {len(sel)} files")
act_hide = menu.addAction(f"Hide {len(sel)} files in profile")
if hidden_sel:
act_unhide = menu.addAction(f"Unhide {len(hidden_sel)} file(s)")
non_hidden = [p for p in sel if p not in hidden_sel]
if non_hidden:
act_hide = menu.addAction(f"Hide {len(non_hidden)} file(s) in profile")
chosen = menu.exec(event.globalPos())
if chosen is None:
return
if chosen == act_remove:
for path in sel:
if path in self._path_set:
@@ -1675,6 +1788,8 @@ class PlaylistWidget(QListWidget):
self._rebuild()
elif chosen == act_hide:
self.hide_requested.emit(sel)
elif chosen == act_unhide:
self.unhide_requested.emit(hidden_sel)
class _KeyFilter(QObject):
@@ -1714,7 +1829,6 @@ def main():
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; }
QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QCheckBox::indicator { width: 14px; height: 14px; }
QStatusBar { color: #aaa; }
QListWidget { background: #252525; alternate-background-color: #2a2a2a; }
QListWidget::item { padding: 4px; color: #ccc; }
QListWidget::item:alternate { color: #ddd; }
@@ -1752,12 +1866,13 @@ class MainWindow(QMainWindow):
self._db_worker: _DBWorker | None = None
self._frame_grabber: FrameGrabber | None = None
self._fps: float = 25.0 # cached on file load via get_fps()
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
# Widgets
self._playlist = PlaylistWidget()
self._playlist.file_selected.connect(self._load_file)
self._playlist.hide_requested.connect(self._on_hide_files)
self._playlist.unhide_requested.connect(self._on_unhide_files)
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
@@ -2065,10 +2180,17 @@ class MainWindow(QMainWindow):
settings_row.addWidget(self._chk_rand_square)
settings_row.addWidget(self._chk_track)
settings_row.addStretch()
self._lbl_status = QLabel()
self._lbl_status.setStyleSheet("color: #888; font-size: 11px;")
self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._status_timer = QTimer(self)
self._status_timer.setSingleShot(True)
self._status_timer.timeout.connect(lambda: self._lbl_status.clear())
settings_row.addWidget(self._lbl_status)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setContentsMargins(0, 0, 4, 0)
right_layout.setSpacing(4)
right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1)
@@ -2083,19 +2205,26 @@ class MainWindow(QMainWindow):
self._btn_open.setToolTip("Add video files to the queue")
self._btn_open.clicked.connect(self._on_open_files)
self._chk_hide_exported = QCheckBox("Hide exported")
self._chk_hide_exported = QPushButton("Hide exported")
self._chk_hide_exported.setCheckable(True)
self._chk_hide_exported.setToolTip("Hide files that already have exported clips")
self._chk_hide_exported.setChecked(
self._settings.value("hide_exported", "false") == "true"
)
self._chk_hide_exported.toggled.connect(self._on_hide_exported_toggled)
self._btn_show_hidden = QPushButton("Show Hidden")
self._btn_show_hidden.setCheckable(True)
self._btn_show_hidden.setToolTip("Reveal hidden files so you can right-click to unhide them")
self._btn_show_hidden.toggled.connect(self._on_show_hidden_toggled)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_top = QHBoxLayout()
left_top.addWidget(self._btn_open)
left_top.addWidget(self._chk_hide_exported)
left_top.addWidget(self._btn_show_hidden)
left_layout.addLayout(left_top)
left_layout.addWidget(self._playlist)
@@ -2108,7 +2237,7 @@ class MainWindow(QMainWindow):
splitter.setCollapsible(1, False)
self.setCentralWidget(splitter)
self.setStatusBar(QStatusBar())
self.setStatusBar(None)
if saved_ratio != "Off":
self._crop_bar.setVisible(True)
self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center)
@@ -2236,12 +2365,32 @@ class MainWindow(QMainWindow):
if self._file_path:
self._refresh_markers()
_log(f"Profile switched: {text}")
self.statusBar().showMessage(f"Profile: {text}", 3000)
self._show_status(f"Profile: {text}", 3000)
def _show_status(self, msg: str, timeout: int = 0) -> None:
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
self._lbl_status.setText(msg)
if timeout > 0:
self._status_timer.start(timeout)
else:
self._status_timer.stop()
def _on_hide_exported_toggled(self, hide: bool) -> None:
self._settings.setValue("hide_exported", "true" if hide else "false")
self._playlist.set_hide_exported(hide)
def _on_show_hidden_toggled(self, show: bool) -> None:
self._playlist.set_show_hidden(show)
def _on_unhide_files(self, paths: list[str]) -> None:
"""Remove files from the hidden list in the current profile."""
for path in paths:
basename = os.path.basename(path)
self._db.unhide_file(basename, self._profile)
self._playlist._hidden_basenames.discard(basename)
self._playlist._rebuild()
_log(f"Unhid {len(paths)} file(s) in profile {self._profile}")
def _on_hide_files(self, paths: list[str]) -> None:
"""Persistently hide files in the current profile."""
for path in paths:
@@ -2319,9 +2468,9 @@ class MainWindow(QMainWindow):
if os.path.basename(self._file_path) != queried:
return
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
self._show_status(f"⚠ Similar to already processed: {match}")
else:
self.statusBar().clearMessage()
self._lbl_status.clear()
self._timeline.set_markers(markers)
def _refresh_markers(self) -> None:
@@ -2348,18 +2497,18 @@ class MainWindow(QMainWindow):
self._update_next_label()
n = len(deleted) if deleted else 1
_log(f"Deleted marker: {n} clip(s) from DB")
self.statusBar().showMessage(
self._show_status(
f"Deleted marker ({n} clip{'s' if n != 1 else ''})", 4000
)
def _on_delete_keyframe(self, time: float) -> None:
self._crop_keyframes = [
(t, c) for t, c in self._crop_keyframes
if abs(t - time) > 0.05
kf for kf in self._crop_keyframes
if abs(kf[0] - time) > 0.05
]
self._timeline.set_crop_keyframes(self._crop_keyframes)
_log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)")
self.statusBar().showMessage(f"Deleted keyframe @ {format_time(time)}", 3000)
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
self._overwrite_path = output_path
@@ -2404,7 +2553,7 @@ class MainWindow(QMainWindow):
self._crop_bar.set_crop_center(self._crop_center)
if ratio != "Off":
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
self.statusBar().showMessage(
self._show_status(
f"Overwrite mode: {group_dir} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
)
@@ -2470,7 +2619,7 @@ class MainWindow(QMainWindow):
self._update_next_label()
self._refresh_markers()
self._refresh_playlist_checks()
self.statusBar().showMessage(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_dir}")
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_dir}")
def _on_portrait_ratio_changed(self, text: str) -> None:
ratio = None if text == "Off" else text
@@ -2485,11 +2634,45 @@ class MainWindow(QMainWindow):
self._update_preview_crop()
def _on_rand_toggle(self, _checked: bool = False) -> None:
if self._btn_lock.isChecked():
self._set_or_remove_crop_keyframe()
ratio_text = self._cmb_portrait.currentText()
if ratio_text != "Off":
return # manual portrait already controls the overlay
self._update_rand_overlays()
def _set_or_remove_crop_keyframe(self) -> None:
"""In lock mode, create a keyframe at the current playback position.
If the resulting keyframe carries no crop modifications (no ratio,
no random flags), remove it instead — this handles the undo case
where the user toggles back to the default state.
"""
play_t = self._timeline._play_pos
if play_t is None:
play_t = self._cursor
if play_t < 0.1:
return
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()
# Remove any existing keyframe at this time.
self._crop_keyframes = [
kf for kf in self._crop_keyframes
if abs(kf[0] - play_t) > 0.05
]
# Only insert if the keyframe carries crop modifications.
if kf_ratio is not None or kf_rand_p or kf_rand_s:
center = self._crop_center
self._crop_keyframes.append(
(play_t, center, kf_ratio, kf_rand_p, kf_rand_s))
self._crop_keyframes.sort()
_log(f"Auto keyframe: t={play_t:.2f}s ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s}")
else:
_log(f"Removed keyframe @ {format_time(play_t)} (no crop modifications)")
self._timeline.set_crop_keyframes(self._crop_keyframes)
def _update_rand_overlays(self) -> None:
"""Show lines-only overlay guides for whichever random crop options are on."""
portrait_on = self._chk_rand_portrait.isChecked()
@@ -2521,15 +2704,21 @@ class MainWindow(QMainWindow):
play_t = self._timeline._play_pos
if play_t is None:
play_t = self._cursor
if play_t < 0.1:
return
# Replace existing keyframe at same time, or insert sorted.
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 = [
(t, c) for t, c in self._crop_keyframes
if abs(t - play_t) > 0.05
kf for kf in self._crop_keyframes
if abs(kf[0] - play_t) > 0.05
]
self._crop_keyframes.append((play_t, frac))
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} ({len(self._crop_keyframes)} total)")
_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)")
self._crop_center = frac
self._crop_bar.set_crop_center(frac)
if ratio != "Off":
@@ -2609,16 +2798,17 @@ class MainWindow(QMainWindow):
self._mpv.seek(t)
# Update crop bar to show the effective center at this time.
if self._crop_keyframes:
center = self._crop_center
for kt, kc in self._crop_keyframes:
if kt <= t + 0.05:
center = kc
else:
break
kf = resolve_keyframe(self._crop_keyframes, t)
if kf is not None:
_, center, ratio, _rp, _rs = kf
self._crop_bar.set_crop_center(center)
ratio = self._cmb_portrait.currentText()
if ratio != "Off":
if ratio is not None:
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
else:
self._update_rand_overlays()
else:
self._crop_bar.set_crop_center(self._crop_center)
self._update_rand_overlays()
def _on_cursor_changed(self, t: float):
self._cursor = t
@@ -2711,7 +2901,7 @@ class MainWindow(QMainWindow):
if not self._file_path:
return
if self._export_worker and self._export_worker.isRunning():
self.statusBar().showMessage("Export already running…")
self._show_status("Export already running…")
return
fmt = self._cmb_format.currentText()
@@ -2736,6 +2926,17 @@ class MainWindow(QMainWindow):
jobs.append((start, path, base_ratio, base_center))
self._overwrite_path = ""
self._overwrite_group = []
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,
)
# Overwrite re-exports use the keyframe's ratio directly
# (no random sampling) to reproduce the original output.
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
else:
name = self._txt_name.text() or "clip"
n_clips = self._spn_clips.value()
@@ -2751,40 +2952,39 @@ class MainWindow(QMainWindow):
out = build_export_path(folder, name, self._export_counter, sub=sub)
jobs.append((start, out, base_ratio, base_center))
# Apply crop keyframes: each sub-clip uses the latest keyframe
# at or before its start time (keyframes set in lock mode).
if self._crop_keyframes:
for i, (s, o, r, c) in enumerate(jobs):
center = base_center
for kt, kc in self._crop_keyframes:
if kt <= s + 0.05:
center = kc
else:
break
jobs[i] = (s, o, r, center)
# Random crop: ~1 per 3 clips gets a random crop + random position.
# When both portrait and square are on, they share the quota.
# Apply crop keyframes (or fall back to base state).
rand_portrait = self._chk_rand_portrait.isChecked()
rand_square = self._chk_rand_square.isChecked()
if (rand_portrait or rand_square) and n_clips > 1:
n_random = max(1, n_clips // 3)
indices = random.sample(range(n_clips), n_random)
# Build pool of ratios to assign
if rand_portrait and rand_square:
ratios = ["9:16", "1:1"]
elif rand_portrait:
ratios = ["9:16"]
else:
ratios = ["1:1"]
for idx in indices:
s, o, _, c = jobs[idx]
jobs[idx] = (s, o, random.choice(ratios), c)
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))
# Subject tracking: re-detect crop center per sub-clip.
if self._chk_track.isChecked() and any(j[2] for j in jobs):
starts = [j[0] for j in jobs]
self.statusBar().showMessage(f"Tracking subject across {len(jobs)} clip(s)…")
self._show_status(f"Tracking subject across {len(jobs)} clip(s)…")
QApplication.processEvents()
centers = track_centers_for_jobs(
self._file_path, self._cursor, base_center, starts,
@@ -2806,7 +3006,7 @@ class MainWindow(QMainWindow):
self._export_spread = self._spn_spread.value()
self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {len(jobs)} clip(s)…")
self._show_status(f"Exporting {len(jobs)} clip(s)…")
# Show one pending marker at the cursor position for the whole batch.
first_out = jobs[0][1]
@@ -2858,7 +3058,7 @@ class MainWindow(QMainWindow):
upsert_clip_annotation(folder, path, label)
self._last_export_path = path
_log(f" clip done: {os.path.basename(path)}")
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._show_status(f"Exported: {os.path.basename(path)}")
def _on_batch_done(self):
"""Called once after all clips in the batch are done."""
@@ -2869,6 +3069,11 @@ class MainWindow(QMainWindow):
self._btn_export.setEnabled(True)
self._btn_export.setText("Export")
self._btn_export.setStyleSheet("")
if self._last_export_path:
group = os.path.basename(os.path.dirname(self._last_export_path))
self._show_status(f"Export complete: {group}")
else:
self._show_status("Export complete")
self._btn_delete.setEnabled(True)
self._btn_delete.setText("Delete")
self._refresh_markers()
@@ -2891,13 +3096,13 @@ class MainWindow(QMainWindow):
self._btn_export.setText("Export")
self._btn_export.setStyleSheet("")
self._refresh_markers() # remove stale pending marker
self.statusBar().showMessage(f"Export error: {msg}")
self._show_status(f"Export error: {msg}")
def _on_cancel_export(self):
if self._export_worker and self._export_worker.isRunning():
self._btn_cancel.setEnabled(False)
self._export_worker.cancel()
self.statusBar().showMessage("Cancelling export…")
self._show_status("Cancelling export…")
def _on_export_cancelled(self):
_log("Export cancelled")
@@ -2909,7 +3114,7 @@ class MainWindow(QMainWindow):
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
if markers:
self._playlist.mark_done(self._file_path, len(markers))
self.statusBar().showMessage("Export cancelled", 4000)
self._show_status("Export cancelled", 4000)
def changeEvent(self, event):
super().changeEvent(event)
@@ -2939,11 +3144,12 @@ class MainWindow(QMainWindow):
def moveEvent(self, event):
super().moveEvent(event)
self._preview_win.follow_main()
# Defer follow_main so the window manager has committed the new geometry.
QTimer.singleShot(0, self._preview_win.follow_main)
def resizeEvent(self, event):
super().resizeEvent(event)
self._preview_win.follow_main()
QTimer.singleShot(0, self._preview_win.follow_main)
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
+59
View File
@@ -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"
+70 -1
View File
@@ -1,5 +1,5 @@
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
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
@@ -369,3 +369,72 @@ def test_db_default_profile_backward_compat():
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)]