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>
This commit is contained in:
2026-04-14 22:40:44 +02:00
parent bd4e97c45a
commit 5b4e4bf818
3 changed files with 297 additions and 5 deletions
+127
View File
@@ -0,0 +1,127 @@
name: Build & Release
on:
push:
tags:
- "v*" # trigger on version tags like v1.0.0
workflow_dispatch: # allow manual trigger
permissions:
contents: write # needed to create releases
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
artifact: 8cut-windows
- os: macos-13 # Intel Mac
artifact: 8cut-macos-x86_64
- os: macos-latest # Apple Silicon
artifact: 8cut-macos-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# ── Install Python deps ──────────────────────────────────
- name: Install Python dependencies
run: |
pip install pyinstaller PyQt6 python-mpv
# ── Windows: fetch ffmpeg + libmpv ───────────────────────
- name: Fetch ffmpeg (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# ffmpeg static build
$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 (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# shinchiro libmpv dev build
$mpvUrl = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/latest"
# Get redirect URL to find latest tag
$release = Invoke-WebRequest $mpvUrl -MaximumRedirection 0 -ErrorAction SilentlyContinue
$tag = ($release.Headers.Location -split '/')[-1]
$dlUrl = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/$tag/mpv-dev-x86_64-v3-${tag}.7z"
Invoke-WebRequest $dlUrl -OutFile mpv-dev.7z
7z x mpv-dev.7z -ompv-dev
Copy-Item mpv-dev\libmpv-2.dll .
# ── macOS: install via Homebrew ──────────────────────────
- name: Install native deps (macOS)
if: runner.os == 'macOS'
run: |
brew install mpv ffmpeg
# Copy dylibs so PyInstaller bundles them
MPV_LIB=$(brew --prefix mpv)/lib/libmpv.2.dylib
cp "$MPV_LIB" .
cp "$(brew --prefix ffmpeg)/bin/ffmpeg" .
cp "$(brew --prefix ffmpeg)/bin/ffprobe" .
# ── Build ────────────────────────────────────────────────
- name: Build with PyInstaller
run: pyinstaller 8cut.spec
# ── Fix macOS dylib paths ────────────────────────────────
- name: Fix dylib rpaths (macOS)
if: runner.os == 'macOS'
run: |
# Rewrite libmpv load path to be relative
DYLIB="dist/8cut/libmpv.2.dylib"
if [ -f "$DYLIB" ]; then
install_name_tool -id @executable_path/libmpv.2.dylib "$DYLIB"
fi
# ── Package ──────────────────────────────────────────────
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Compress-Archive -Path dist\8cut\* -DestinationPath ${{ matrix.artifact }}.zip
- name: Package (macOS)
if: runner.os == 'macOS'
run: |
cd dist
zip -r ../${{ matrix.artifact }}.zip 8cut.app
# ── Upload artifact ──────────────────────────────────────
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}.zip
# ── Create GitHub Release ──────────────────────────────────
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: artifacts/**/*.zip
+143
View File
@@ -0,0 +1,143 @@
# -*- 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=False,
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=True,
console=False, # GUI app
)
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=True,
upx_exclude=[],
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",
},
)
+27 -5
View File
@@ -24,9 +24,31 @@ from PyQt6.QtWidgets import (
) )
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 getattr(sys, "frozen", False):
# In frozen builds, help ctypes find bundled libmpv
_bundle = Path(sys._MEIPASS)
if sys.platform == "win32":
os.add_dll_directory(str(_bundle))
elif sys.platform == "darwin":
os.environ.setdefault("DYLD_LIBRARY_PATH", str(_bundle))
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 +125,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 +179,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 +251,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 +326,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 +759,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",