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 (
|
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}")
|
||||||
|
if hidden_sel:
|
||||||
|
act_unhide = menu.addAction(f"Unhide: {name}")
|
||||||
|
else:
|
||||||
act_hide = menu.addAction(f"Hide in profile: {name}")
|
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():
|
||||||
|
|||||||
@@ -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