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.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
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 +125,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 +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."""
audio_path = sequence_dir + ".wav"
return [
"ffmpeg", "-y",
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
@@ -229,7 +251,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 +326,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 +759,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",