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",
},
)
+154 -30
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")
@@ -103,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:
@@ -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."""
audio_path = sequence_dir + ".wav"
return [
"ffmpeg", "-y",
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
@@ -229,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:
@@ -304,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:
@@ -737,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",
@@ -1027,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)
@@ -1580,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
@@ -1598,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}")
@@ -1654,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()
@@ -1712,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)]
@@ -1724,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:
@@ -1742,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):
@@ -1781,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; }
@@ -1825,6 +1872,7 @@ class MainWindow(QMainWindow):
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)
@@ -2132,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)
@@ -2150,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)
@@ -2175,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)
@@ -2303,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:
@@ -2386,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:
@@ -2415,7 +2497,7 @@ 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
)
@@ -2426,7 +2508,7 @@ class MainWindow(QMainWindow):
]
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
@@ -2471,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
)
@@ -2537,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
@@ -2552,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()
@@ -2588,6 +2704,8 @@ 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
@@ -2783,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()
@@ -2866,7 +2984,7 @@ class MainWindow(QMainWindow):
# 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,
@@ -2888,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]
@@ -2940,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."""
@@ -2951,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()
@@ -2973,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")
@@ -2991,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)
@@ -3021,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"