diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98c802f --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/8cut.spec b/8cut.spec new file mode 100644 index 0000000..8e64219 --- /dev/null +++ b/8cut.spec @@ -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", + }, + ) diff --git a/main.py b/main.py index 399b7cd..b621dc8 100755 --- a/main.py +++ b/main.py @@ -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 .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",