Compare commits
19 Commits
cb4392125d
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dd2d00a0 | |||
| 34d8ad1dc7 | |||
| 46bd617f0a | |||
| e8ecfc0525 | |||
| 198ec68382 | |||
| 920f724dbd | |||
| 94ea4c63ca | |||
| 653e4a5e13 | |||
| cd50b3ae0c | |||
| 10b77e79f7 | |||
| 5b4e4bf818 | |||
| bd4e97c45a | |||
| 1aeaad7f6d | |||
| 874632dffa | |||
| 86055f2072 | |||
| 5fddb06354 | |||
| e60263548d | |||
| 86f447f3d6 | |||
| 1d5b8023a2 |
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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}")
|
||||
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:
|
||||
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():
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user