19 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
5 changed files with 474 additions and 31 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
+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",
},
)
+155 -31
View File
@@ -17,16 +17,37 @@ from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar, QLabel, QPushButton, QLineEdit, QFileDialog, QFrame,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog, QMessageBox, QInputDialog,
) )
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings 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 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 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: def _log(*args) -> None:
"""Print a timestamped log line to stderr.""" """Print a timestamped log line to stderr."""
ts = datetime.now().strftime("%H:%M:%S") ts = datetime.now().strftime("%H:%M:%S")
@@ -103,7 +124,7 @@ def build_ffmpeg_command(
# so there is no keyframe-alignment issue from pre-input seek. # so there is no keyframe-alignment issue from pre-input seek.
# Image sequences always use libwebp, so skip HW encoder setup. # Image sequences always use libwebp, so skip HW encoder setup.
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence
cmd = ["ffmpeg", "-y"] cmd = [_bin("ffmpeg"), "-y"]
# VAAPI needs a device for hardware context. # VAAPI needs a device for hardware context.
if use_hw_vaapi: if use_hw_vaapi:
@@ -157,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.""" """Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
audio_path = sequence_dir + ".wav" audio_path = sequence_dir + ".wav"
return [ return [
"ffmpeg", "-y", _bin("ffmpeg"), "-y",
"-ss", str(start), "-ss", str(start),
"-i", input_path, "-i", input_path,
"-t", "8", "-t", "8",
@@ -229,7 +250,7 @@ def detect_hw_encoders() -> list[str]:
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"] _HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
try: try:
result = subprocess.run( result = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"], [_bin("ffmpeg"), "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -304,7 +325,7 @@ def extract_frame_cv(video_path: str, time: float):
fd, tmp = tempfile.mkstemp(suffix=".png") fd, tmp = tempfile.mkstemp(suffix=".png")
os.close(fd) os.close(fd)
try: try:
cmd = ["ffmpeg", "-y", "-ss", str(time), "-i", video_path, cmd = [_bin("ffmpeg"), "-y", "-ss", str(time), "-i", video_path,
"-frames:v", "1", tmp] "-frames:v", "1", tmp]
result = subprocess.run(cmd, capture_output=True, timeout=10) result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode != 0: if result.returncode != 0:
@@ -737,7 +758,7 @@ class FrameGrabber(QThread):
def run(self): def run(self):
try: try:
cmd = [ cmd = [
"ffmpeg", "-ss", str(self._time), _bin("ffmpeg"), "-ss", str(self._time),
"-i", self._input, "-i", self._input,
"-frames:v", "1", "-frames:v", "1",
"-f", "image2pipe", "-vcodec", "png", "-f", "image2pipe", "-vcodec", "png",
@@ -1027,7 +1048,7 @@ class TimelineWidget(QWidget):
def _emit_seek(self): def _emit_seek(self):
if self._locked: 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: else:
self.cursor_changed.emit(self._cursor) self.cursor_changed.emit(self._cursor)
@@ -1580,13 +1601,14 @@ class PlaylistWidget(QListWidget):
self._done_counts: dict[str, int] = {} # path → clip count self._done_counts: dict[str, int] = {} # path → clip count
self._hidden_basenames: set[str] = set() self._hidden_basenames: set[str] = set()
self._hide_exported = False self._hide_exported = False
self._show_hidden = False
self._visible: list[str] = [] # paths currently shown in widget self._visible: list[str] = [] # paths currently shown in widget
self._selected_path: str | None = None self._selected_path: str | None = None
self.itemClicked.connect(self._on_item_clicked) self.itemClicked.connect(self._on_item_clicked)
def _is_visible(self, path: str) -> bool: def _is_visible(self, path: str) -> bool:
if os.path.basename(path) in self._hidden_basenames: 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: if self._hide_exported and path in self._done_set:
return False return False
return True return True
@@ -1598,7 +1620,14 @@ class PlaylistWidget(QListWidget):
self._visible = [p for p in self._paths if self._is_visible(p)] self._visible = [p for p in self._paths if self._is_visible(p)]
for path in self._visible: for path in self._visible:
name = os.path.basename(path) 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) n = self._done_counts.get(path, 0)
tag = f"[{n}]" if n else "" tag = f"[{n}]" if n else ""
item = QListWidgetItem(f"{tag} {name}") item = QListWidgetItem(f"{tag} {name}")
@@ -1654,6 +1683,10 @@ class PlaylistWidget(QListWidget):
self._hidden_basenames = basenames self._hidden_basenames = basenames
self._rebuild() self._rebuild()
def set_show_hidden(self, show: bool) -> None:
self._show_hidden = show
self._rebuild()
def set_hide_exported(self, hide: bool) -> None: def set_hide_exported(self, hide: bool) -> None:
self._hide_exported = hide self._hide_exported = hide
self._rebuild() self._rebuild()
@@ -1712,6 +1745,7 @@ class PlaylistWidget(QListWidget):
self._select(self.row(item)) self._select(self.row(item))
hide_requested = pyqtSignal(list) # emits list of full paths to hide 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]: def _selected_paths(self) -> list[str]:
return [self._visible[self.row(it)] return [self._visible[self.row(it)]
@@ -1724,14 +1758,26 @@ class PlaylistWidget(QListWidget):
return return
from PyQt6.QtWidgets import QMenu from PyQt6.QtWidgets import QMenu
menu = QMenu(self) 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: if len(sel) == 1:
name = os.path.basename(sel[0]) name = os.path.basename(sel[0])
act_remove = menu.addAction(f"Remove: {name}") act_remove = menu.addAction(f"Remove: {name}")
act_hide = menu.addAction(f"Hide in profile: {name}") if hidden_sel:
act_unhide = menu.addAction(f"Unhide: {name}")
else:
act_hide = menu.addAction(f"Hide in profile: {name}")
else: else:
act_remove = menu.addAction(f"Remove {len(sel)} files") 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()) chosen = menu.exec(event.globalPos())
if chosen is None:
return
if chosen == act_remove: if chosen == act_remove:
for path in sel: for path in sel:
if path in self._path_set: if path in self._path_set:
@@ -1742,6 +1788,8 @@ class PlaylistWidget(QListWidget):
self._rebuild() self._rebuild()
elif chosen == act_hide: elif chosen == act_hide:
self.hide_requested.emit(sel) self.hide_requested.emit(sel)
elif chosen == act_unhide:
self.unhide_requested.emit(hidden_sel)
class _KeyFilter(QObject): class _KeyFilter(QObject):
@@ -1781,7 +1829,6 @@ def main():
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; } 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; } QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QCheckBox::indicator { width: 14px; height: 14px; } QCheckBox::indicator { width: 14px; height: 14px; }
QStatusBar { color: #aaa; }
QListWidget { background: #252525; alternate-background-color: #2a2a2a; } QListWidget { background: #252525; alternate-background-color: #2a2a2a; }
QListWidget::item { padding: 4px; color: #ccc; } QListWidget::item { padding: 4px; color: #ccc; }
QListWidget::item:alternate { color: #ddd; } QListWidget::item:alternate { color: #ddd; }
@@ -1825,6 +1872,7 @@ class MainWindow(QMainWindow):
self._playlist = PlaylistWidget() self._playlist = PlaylistWidget()
self._playlist.file_selected.connect(self._load_file) self._playlist.file_selected.connect(self._load_file)
self._playlist.hide_requested.connect(self._on_hide_files) self._playlist.hide_requested.connect(self._on_hide_files)
self._playlist.unhide_requested.connect(self._on_unhide_files)
self._mpv = MpvWidget() self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load) self._mpv.file_loaded.connect(self._after_load)
@@ -2132,10 +2180,17 @@ class MainWindow(QMainWindow):
settings_row.addWidget(self._chk_rand_square) settings_row.addWidget(self._chk_rand_square)
settings_row.addWidget(self._chk_track) settings_row.addWidget(self._chk_track)
settings_row.addStretch() 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 = QWidget()
right_layout = QVBoxLayout(right) right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setContentsMargins(0, 0, 4, 0)
right_layout.setSpacing(4) right_layout.setSpacing(4)
right_layout.addLayout(top_bar) right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1) right_layout.addWidget(self._mpv, stretch=1)
@@ -2150,19 +2205,26 @@ class MainWindow(QMainWindow):
self._btn_open.setToolTip("Add video files to the queue") self._btn_open.setToolTip("Add video files to the queue")
self._btn_open.clicked.connect(self._on_open_files) 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.setToolTip("Hide files that already have exported clips")
self._chk_hide_exported.setChecked( self._chk_hide_exported.setChecked(
self._settings.value("hide_exported", "false") == "true" self._settings.value("hide_exported", "false") == "true"
) )
self._chk_hide_exported.toggled.connect(self._on_hide_exported_toggled) 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 = QWidget()
left_layout = QVBoxLayout(left) left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4) left_layout.setContentsMargins(4, 4, 4, 4)
left_top = QHBoxLayout() left_top = QHBoxLayout()
left_top.addWidget(self._btn_open) left_top.addWidget(self._btn_open)
left_top.addWidget(self._chk_hide_exported) left_top.addWidget(self._chk_hide_exported)
left_top.addWidget(self._btn_show_hidden)
left_layout.addLayout(left_top) left_layout.addLayout(left_top)
left_layout.addWidget(self._playlist) left_layout.addWidget(self._playlist)
@@ -2175,7 +2237,7 @@ class MainWindow(QMainWindow):
splitter.setCollapsible(1, False) splitter.setCollapsible(1, False)
self.setCentralWidget(splitter) self.setCentralWidget(splitter)
self.setStatusBar(QStatusBar()) self.setStatusBar(None)
if saved_ratio != "Off": if saved_ratio != "Off":
self._crop_bar.setVisible(True) self._crop_bar.setVisible(True)
self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center) self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center)
@@ -2303,12 +2365,32 @@ class MainWindow(QMainWindow):
if self._file_path: if self._file_path:
self._refresh_markers() self._refresh_markers()
_log(f"Profile switched: {text}") _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: def _on_hide_exported_toggled(self, hide: bool) -> None:
self._settings.setValue("hide_exported", "true" if hide else "false") self._settings.setValue("hide_exported", "true" if hide else "false")
self._playlist.set_hide_exported(hide) 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: def _on_hide_files(self, paths: list[str]) -> None:
"""Persistently hide files in the current profile.""" """Persistently hide files in the current profile."""
for path in paths: for path in paths:
@@ -2386,9 +2468,9 @@ class MainWindow(QMainWindow):
if os.path.basename(self._file_path) != queried: if os.path.basename(self._file_path) != queried:
return return
if match: if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}") self._show_status(f"⚠ Similar to already processed: {match}")
else: else:
self.statusBar().clearMessage() self._lbl_status.clear()
self._timeline.set_markers(markers) self._timeline.set_markers(markers)
def _refresh_markers(self) -> None: def _refresh_markers(self) -> None:
@@ -2415,7 +2497,7 @@ class MainWindow(QMainWindow):
self._update_next_label() self._update_next_label()
n = len(deleted) if deleted else 1 n = len(deleted) if deleted else 1
_log(f"Deleted marker: {n} clip(s) from DB") _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 f"Deleted marker ({n} clip{'s' if n != 1 else ''})", 4000
) )
@@ -2426,7 +2508,7 @@ class MainWindow(QMainWindow):
] ]
self._timeline.set_crop_keyframes(self._crop_keyframes) self._timeline.set_crop_keyframes(self._crop_keyframes)
_log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)") _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: def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
self._overwrite_path = output_path self._overwrite_path = output_path
@@ -2471,7 +2553,7 @@ class MainWindow(QMainWindow):
self._crop_bar.set_crop_center(self._crop_center) self._crop_bar.set_crop_center(self._crop_center)
if ratio != "Off": if ratio != "Off":
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center) 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 f"Overwrite mode: {group_dir} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
) )
@@ -2537,7 +2619,7 @@ class MainWindow(QMainWindow):
self._update_next_label() self._update_next_label()
self._refresh_markers() self._refresh_markers()
self._refresh_playlist_checks() 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: def _on_portrait_ratio_changed(self, text: str) -> None:
ratio = None if text == "Off" else text ratio = None if text == "Off" else text
@@ -2552,11 +2634,45 @@ class MainWindow(QMainWindow):
self._update_preview_crop() self._update_preview_crop()
def _on_rand_toggle(self, _checked: bool = False) -> None: 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() ratio_text = self._cmb_portrait.currentText()
if ratio_text != "Off": if ratio_text != "Off":
return # manual portrait already controls the overlay return # manual portrait already controls the overlay
self._update_rand_overlays() 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: def _update_rand_overlays(self) -> None:
"""Show lines-only overlay guides for whichever random crop options are on.""" """Show lines-only overlay guides for whichever random crop options are on."""
portrait_on = self._chk_rand_portrait.isChecked() portrait_on = self._chk_rand_portrait.isChecked()
@@ -2588,6 +2704,8 @@ class MainWindow(QMainWindow):
play_t = self._timeline._play_pos play_t = self._timeline._play_pos
if play_t is None: if play_t is None:
play_t = self._cursor play_t = self._cursor
if play_t < 0.1:
return
# Replace existing keyframe at same time, or insert sorted. # Replace existing keyframe at same time, or insert sorted.
ratio_text = self._cmb_portrait.currentText() ratio_text = self._cmb_portrait.currentText()
kf_ratio = None if ratio_text == "Off" else ratio_text kf_ratio = None if ratio_text == "Off" else ratio_text
@@ -2783,7 +2901,7 @@ class MainWindow(QMainWindow):
if not self._file_path: if not self._file_path:
return return
if self._export_worker and self._export_worker.isRunning(): if self._export_worker and self._export_worker.isRunning():
self.statusBar().showMessage("Export already running…") self._show_status("Export already running…")
return return
fmt = self._cmb_format.currentText() fmt = self._cmb_format.currentText()
@@ -2866,7 +2984,7 @@ class MainWindow(QMainWindow):
# Subject tracking: re-detect crop center per sub-clip. # Subject tracking: re-detect crop center per sub-clip.
if self._chk_track.isChecked() and any(j[2] for j in jobs): if self._chk_track.isChecked() and any(j[2] for j in jobs):
starts = [j[0] 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() QApplication.processEvents()
centers = track_centers_for_jobs( centers = track_centers_for_jobs(
self._file_path, self._cursor, base_center, starts, self._file_path, self._cursor, base_center, starts,
@@ -2888,7 +3006,7 @@ class MainWindow(QMainWindow):
self._export_spread = self._spn_spread.value() self._export_spread = self._spn_spread.value()
self._btn_export.setEnabled(False) 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. # Show one pending marker at the cursor position for the whole batch.
first_out = jobs[0][1] first_out = jobs[0][1]
@@ -2940,7 +3058,7 @@ class MainWindow(QMainWindow):
upsert_clip_annotation(folder, path, label) upsert_clip_annotation(folder, path, label)
self._last_export_path = path self._last_export_path = path
_log(f" clip done: {os.path.basename(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): def _on_batch_done(self):
"""Called once after all clips in the batch are done.""" """Called once after all clips in the batch are done."""
@@ -2951,6 +3069,11 @@ class MainWindow(QMainWindow):
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._btn_export.setText("Export") self._btn_export.setText("Export")
self._btn_export.setStyleSheet("") 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.setEnabled(True)
self._btn_delete.setText("Delete") self._btn_delete.setText("Delete")
self._refresh_markers() self._refresh_markers()
@@ -2973,13 +3096,13 @@ class MainWindow(QMainWindow):
self._btn_export.setText("Export") self._btn_export.setText("Export")
self._btn_export.setStyleSheet("") self._btn_export.setStyleSheet("")
self._refresh_markers() # remove stale pending marker 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): def _on_cancel_export(self):
if self._export_worker and self._export_worker.isRunning(): if self._export_worker and self._export_worker.isRunning():
self._btn_cancel.setEnabled(False) self._btn_cancel.setEnabled(False)
self._export_worker.cancel() self._export_worker.cancel()
self.statusBar().showMessage("Cancelling export…") self._show_status("Cancelling export…")
def _on_export_cancelled(self): def _on_export_cancelled(self):
_log("Export cancelled") _log("Export cancelled")
@@ -2991,7 +3114,7 @@ class MainWindow(QMainWindow):
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile) markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
if markers: if markers:
self._playlist.mark_done(self._file_path, len(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): def changeEvent(self, event):
super().changeEvent(event) super().changeEvent(event)
@@ -3021,11 +3144,12 @@ class MainWindow(QMainWindow):
def moveEvent(self, event): def moveEvent(self, event):
super().moveEvent(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): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
self._preview_win.follow_main() QTimer.singleShot(0, self._preview_win.follow_main)
def dragEnterEvent(self, event: QDragEnterEvent) -> None: def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls(): 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"