From 2a9a87075a55da04e6b4e405f62cb6c4a54d35de Mon Sep 17 00:00:00 2001 From: Weifeng Date: Thu, 26 Feb 2026 17:13:00 +0000 Subject: [PATCH 1/2] feat: Add PyInstaller packaging and CI/CD build workflow - Add VideoCaptioner.spec for PyInstaller configuration with all resource files (fonts, assets, translations, prompts, subtitle styles) - Add scripts/build.py as the single entry point for building - Update app/config.py to support PyInstaller frozen mode (sys._MEIPASS for bundled resources, exe directory for user data) - Update main.py Qt plugin path handling for frozen mode - Add .github/workflows/build.yml for Windows + macOS CI builds with smoke tests and artifact uploads - Update .gitignore to exclude build/dist directories Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .github/workflows/build.yml | 126 ++++++++++++++++++++++++++++++ .gitignore | 4 + VideoCaptioner.spec | 151 ++++++++++++++++++++++++++++++++++++ app/config.py | 15 +++- main.py | 28 ++++--- scripts/build.py | 136 ++++++++++++++++++++++++++++++++ 6 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 VideoCaptioner.spec create mode 100644 scripts/build.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..cbfb16f3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,126 @@ +name: Build + +on: + push: + branches: [master] + paths: + - "app/**" + - "main.py" + - "resource/**" + - "VideoCaptioner.spec" + - "scripts/build.py" + - "pyproject.toml" + - ".github/workflows/build.yml" + pull_request: + branches: [master] + paths: + - "app/**" + - "main.py" + - "resource/**" + - "VideoCaptioner.spec" + - "scripts/build.py" + - "pyproject.toml" + - ".github/workflows/build.yml" + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact: VideoCaptioner-windows + - os: macos-latest + artifact: VideoCaptioner-macos + + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + python -c " + import tomllib, subprocess, sys + with open('pyproject.toml', 'rb') as f: + data = tomllib.load(f) + deps = data['project']['dependencies'] + subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + deps) + " + + - name: Build with PyInstaller + run: python scripts/build.py --clean + + - name: Verify build (Windows) + if: runner.os == 'Windows' + run: | + $exe = "dist\VideoCaptioner\VideoCaptioner.exe" + if (Test-Path $exe) { + Write-Host "Executable found: $exe" + Write-Host "Size: $([math]::Round((Get-Item $exe).Length / 1MB, 1)) MB" + } else { + Write-Host "ERROR: Executable not found" + exit 1 + } + shell: pwsh + + - name: Verify build (macOS) + if: runner.os == 'macOS' + run: | + EXE="dist/VideoCaptioner/VideoCaptioner" + if [ -f "$EXE" ]; then + echo "Executable found: $EXE" + echo "Size: $(du -h "$EXE" | cut -f1)" + else + echo "ERROR: Executable not found" + exit 1 + fi + if [ -d "dist/VideoCaptioner.app" ]; then + echo "App bundle found: dist/VideoCaptioner.app" + fi + + - name: Smoke test (Windows) + if: runner.os == 'Windows' + run: | + $proc = Start-Process -FilePath "dist\VideoCaptioner\VideoCaptioner.exe" -PassThru + Start-Sleep -Seconds 8 + if (!$proc.HasExited) { + Write-Host "App started successfully (PID: $($proc.Id))" + Stop-Process -Id $proc.Id -Force + } else { + Write-Host "WARNING: App exited with code $($proc.ExitCode)" + if ($proc.ExitCode -ne 0) { exit 1 } + } + shell: pwsh + + - name: Smoke test (macOS) + if: runner.os == 'macOS' + run: | + dist/VideoCaptioner/VideoCaptioner & + APP_PID=$! + sleep 8 + if kill -0 $APP_PID 2>/dev/null; then + echo "App started successfully (PID: $APP_PID)" + kill $APP_PID + else + wait $APP_PID + EXIT_CODE=$? + echo "WARNING: App exited with code $EXIT_CODE" + if [ $EXIT_CODE -ne 0 ]; then exit 1; fi + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/VideoCaptioner/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2dc9f2c5..323da149 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ cookies.txt htmlcov/ *.log +# PyInstaller 构建产物 +/build/ +/dist/ + # 项目文档 CLAUDE.md diff --git a/VideoCaptioner.spec b/VideoCaptioner.spec new file mode 100644 index 00000000..2fda49bb --- /dev/null +++ b/VideoCaptioner.spec @@ -0,0 +1,151 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for VideoCaptioner. + +Usage: + pyinstaller VideoCaptioner.spec +""" + +import sys +from pathlib import Path + +block_cipher = None + +ROOT = Path(SPECPATH) + +# ── Data files to bundle ─────────────────────────────────────────────── +# Format: (source, dest_in_bundle) +datas = [ + # Resource directories + (str(ROOT / "resource" / "assets"), "resource/assets"), + (str(ROOT / "resource" / "fonts"), "resource/fonts"), + (str(ROOT / "resource" / "subtitle_style"), "resource/subtitle_style"), + (str(ROOT / "resource" / "translations"), "resource/translations"), + # Prompt template .md files + (str(ROOT / "app" / "core" / "prompts"), "app/core/prompts"), +] + +# ── Hidden imports ───────────────────────────────────────────────────── +# Modules that PyInstaller can't auto-detect +hiddenimports = [ + # Qt plugins & bindings + "PyQt5", + "PyQt5.QtCore", + "PyQt5.QtGui", + "PyQt5.QtWidgets", + "PyQt5.QtMultimedia", + "PyQt5.QtMultimediaWidgets", + "PyQt5.QtSvg", + "PyQt5.sip", + # qfluentwidgets + "qfluentwidgets", + "qfluentwidgets._rc", + "qfluentwidgets._rc.resource", + "qfluentwidgets.common", + "qfluentwidgets.components", + "qfluentwidgets.multimedia", + "qfluentwidgets.window", + # Core dependencies + "openai", + "requests", + "diskcache", + "yt_dlp", + "modelscope", + "psutil", + "json_repair", + "langdetect", + "pydub", + "tenacity", + "GPUtil", + "PIL", + "PIL.Image", + "PIL.ImageDraw", + "PIL.ImageFont", + "fontTools", + "fontTools.ttLib", + # stdlib modules sometimes missed + "json", + "logging", + "traceback", + "string", + "functools", + "pathlib", + "typing", +] + +# ── Excluded modules (reduce bundle size) ────────────────────────────── +excludes = [ + "tkinter", + "matplotlib", + "scipy", + "numpy.testing", + "pytest", + "pyright", + "ruff", + "test", + "unittest", +] + +a = Analysis( + [str(ROOT / "main.py")], + pathex=[str(ROOT)], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="VideoCaptioner", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, # GUI app, no console window + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(ROOT / "resource" / "assets" / "logo.png"), +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="VideoCaptioner", +) + +# macOS .app bundle +if sys.platform == "darwin": + app = BUNDLE( + coll, + name="VideoCaptioner.app", + icon=str(ROOT / "resource" / "assets" / "logo.png"), + bundle_identifier="com.weifeng.videocaptioner", + info_plist={ + "CFBundleName": "VideoCaptioner", + "CFBundleDisplayName": "VideoCaptioner", + "CFBundleVersion": "1.4.0", + "CFBundleShortVersionString": "1.4.0", + "NSHighResolutionCapable": True, + }, + ) diff --git a/app/config.py b/app/config.py index 31c2baf7..06d2a6f5 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ import logging import os +import sys from pathlib import Path VERSION = "v1.4.0" @@ -13,11 +14,19 @@ FEEDBACK_URL = "https://github.com/WEIFENG2333/VideoCaptioner/issues" # 路径 -ROOT_PATH = Path(__file__).parent.parent +# PyInstaller 打包后,_MEIPASS 指向临时解压目录(包含 resource 等打包资源) +# 可执行文件所在目录用于存放用户数据(AppData, work-dir) +if getattr(sys, "frozen", False): + # PyInstaller frozen mode + ROOT_PATH = Path(sys._MEIPASS) # type: ignore[attr-defined] + _EXE_DIR = Path(sys.executable).parent +else: + ROOT_PATH = Path(__file__).parent.parent + _EXE_DIR = ROOT_PATH RESOURCE_PATH = ROOT_PATH / "resource" -APPDATA_PATH = ROOT_PATH / "AppData" -WORK_PATH = ROOT_PATH / "work-dir" +APPDATA_PATH = _EXE_DIR / "AppData" +WORK_PATH = _EXE_DIR / "work-dir" BIN_PATH = RESOURCE_PATH / "bin" diff --git a/main.py b/main.py index 909b523e..5215e3c9 100644 --- a/main.py +++ b/main.py @@ -16,17 +16,23 @@ project_root = os.path.dirname(os.path.abspath(__file__)) sys.path.append(project_root) -# Use appropriate library folder name based on OS -lib_folder = "Lib" if platform.system() == "Windows" else "lib" -plugin_path = os.path.join( - sys.prefix, lib_folder, "site-packages", "PyQt5", "Qt5", "plugins" -) -os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path - -# Delete pyd files app*.pyd -for file in os.listdir(): - if file.startswith("app") and file.endswith(".pyd"): - os.remove(file) +# Set Qt plugin path +if getattr(sys, "frozen", False): + # PyInstaller bundles Qt plugins alongside the executable + _base = os.path.dirname(sys.executable) + _candidate = os.path.join(_base, "PyQt5", "Qt5", "plugins") + if os.path.isdir(_candidate): + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _candidate +else: + lib_folder = "Lib" if platform.system() == "Windows" else "lib" + plugin_path = os.path.join( + sys.prefix, lib_folder, "site-packages", "PyQt5", "Qt5", "plugins" + ) + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path + # Delete pyd files app*.pyd (development only) + for file in os.listdir(): + if file.startswith("app") and file.endswith(".pyd"): + os.remove(file) # Now import the modules that depend on the setup above from PyQt5.QtCore import Qt, QTranslator # noqa: E402 diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..f3baccdd --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Build script for VideoCaptioner using PyInstaller. + +Usage: + python scripts/build.py # Build for current platform + python scripts/build.py --clean # Clean build artifacts first + +Requirements: + pip install pyinstaller +""" + +import argparse +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +SPEC_FILE = ROOT_DIR / "VideoCaptioner.spec" +DIST_DIR = ROOT_DIR / "dist" +BUILD_DIR = ROOT_DIR / "build" + + +def clean(): + """Remove previous build artifacts.""" + for d in [DIST_DIR, BUILD_DIR]: + if d.exists(): + print(f"Removing {d}") + shutil.rmtree(d) + + +def build(): + """Run PyInstaller with the spec file.""" + if not SPEC_FILE.exists(): + print(f"ERROR: Spec file not found: {SPEC_FILE}") + sys.exit(1) + + cmd = [ + sys.executable, + "-m", + "PyInstaller", + str(SPEC_FILE), + "--noconfirm", + "--distpath", + str(DIST_DIR), + "--workpath", + str(BUILD_DIR), + ] + + print(f"Building VideoCaptioner for {platform.system()} ({platform.machine()})...") + print(f"Command: {' '.join(cmd)}") + print() + + result = subprocess.run(cmd, cwd=str(ROOT_DIR)) + if result.returncode != 0: + print("\nBuild FAILED!") + sys.exit(1) + + # Print output location + output_dir = DIST_DIR / "VideoCaptioner" + if platform.system() == "Darwin": + app_bundle = DIST_DIR / "VideoCaptioner.app" + if app_bundle.exists(): + print(f"\nmacOS app bundle: {app_bundle}") + if output_dir.exists(): + print(f"\nBuild output: {output_dir}") + + print("\nBuild SUCCESS!") + + +def verify(): + """Basic verification that the build output exists and has expected files.""" + output_dir = DIST_DIR / "VideoCaptioner" + if not output_dir.exists(): + print("ERROR: Build output directory not found") + sys.exit(1) + + # Check executable + if platform.system() == "Windows": + exe = output_dir / "VideoCaptioner.exe" + else: + exe = output_dir / "VideoCaptioner" + + if not exe.exists(): + print(f"ERROR: Executable not found: {exe}") + sys.exit(1) + + # PyInstaller places bundled data in _internal/ directory + internal_dir = output_dir / "_internal" + data_root = internal_dir if internal_dir.exists() else output_dir + + # Check resource directories are bundled + expected_resources = [ + "resource/assets/logo.png", + "resource/fonts/LXGWWenKai-Regular.ttf", + "resource/subtitle_style/default.json", + "resource/translations", + "app/core/prompts/split/semantic.md", + ] + + missing = [] + for res in expected_resources: + if not (data_root / res).exists(): + missing.append(res) + + if missing: + print("WARNING: Missing bundled resources:") + for m in missing: + print(f" - {m}") + else: + print("All expected resources found in bundle.") + + print(f"\nExecutable size: {exe.stat().st_size / (1024*1024):.1f} MB") + + +def main(): + parser = argparse.ArgumentParser(description="Build VideoCaptioner") + parser.add_argument("--clean", action="store_true", help="Clean build artifacts first") + parser.add_argument("--verify-only", action="store_true", help="Only verify existing build") + args = parser.parse_args() + + if args.verify_only: + verify() + return + + if args.clean: + clean() + + build() + verify() + + +if __name__ == "__main__": + main() From 48f86b04595bcffc0b4b41200b49fef31ef97444 Mon Sep 17 00:00:00 2001 From: Weifeng Date: Thu, 26 Feb 2026 17:34:10 +0000 Subject: [PATCH 2/2] feat: Download ffmpeg/7z for Windows builds, fix writable resource paths - build.py: Auto-download ffmpeg and 7z.exe to resource/bin/ on Windows - build.py: Copy writable resources (bin/, subtitle_style/) to dist output - config.py: BIN_PATH and SUBTITLE_STYLE_PATH now point to _EXE_DIR (writable) instead of _MEIPASS (read-only) in frozen mode - config.py: First-run copytree for preset subtitle styles from _MEIPASS - build.yml: Add bin verification step for Windows Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .github/workflows/build.yml | 10 +++++ app/config.py | 18 ++++++-- scripts/build.py | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbfb16f3..da151b3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,6 +71,16 @@ jobs: Write-Host "ERROR: Executable not found" exit 1 } + # Verify ffmpeg and 7z are present + $binDir = "dist\VideoCaptioner\resource\bin" + foreach ($bin in @("ffmpeg.exe", "7z.exe")) { + $p = Join-Path $binDir $bin + if (Test-Path $p) { + Write-Host " $bin found ($([math]::Round((Get-Item $p).Length / 1MB, 1)) MB)" + } else { + Write-Host " WARNING: $bin not found" + } + } shell: pwsh - name: Verify build (macOS) diff --git a/app/config.py b/app/config.py index 06d2a6f5..663073ab 100644 --- a/app/config.py +++ b/app/config.py @@ -28,10 +28,11 @@ APPDATA_PATH = _EXE_DIR / "AppData" WORK_PATH = _EXE_DIR / "work-dir" - -BIN_PATH = RESOURCE_PATH / "bin" +# bin 目录需要可写(运行时会下载 Faster-Whisper 等),放在 exe 目录下 +BIN_PATH = _EXE_DIR / "resource" / "bin" ASSETS_PATH = RESOURCE_PATH / "assets" -SUBTITLE_STYLE_PATH = RESOURCE_PATH / "subtitle_style" +# subtitle_style 需要可写(用户保存自定义样式),放在 exe 目录下 +SUBTITLE_STYLE_PATH = _EXE_DIR / "resource" / "subtitle_style" TRANSLATIONS_PATH = RESOURCE_PATH / "translations" FONTS_PATH = RESOURCE_PATH / "fonts" @@ -57,3 +58,14 @@ # 创建路径 for p in [CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH]: p.mkdir(parents=True, exist_ok=True) + +# PyInstaller frozen mode: 将预置的可写资源从 _MEIPASS 拷贝到 exe 目录(首次运行) +if getattr(sys, "frozen", False): + import shutil + + _bundled_resource = Path(sys._MEIPASS) / "resource" # type: ignore[attr-defined] + for _dir_name in ("subtitle_style",): + _src = _bundled_resource / _dir_name + _dst = _EXE_DIR / "resource" / _dir_name + if _src.exists() and not _dst.exists(): + shutil.copytree(_src, _dst) diff --git a/scripts/build.py b/scripts/build.py index f3baccdd..646920e5 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -11,10 +11,13 @@ """ import argparse +import io import platform import shutil import subprocess import sys +import urllib.request +import zipfile from pathlib import Path ROOT_DIR = Path(__file__).resolve().parent.parent @@ -22,6 +25,10 @@ DIST_DIR = ROOT_DIR / "dist" BUILD_DIR = ROOT_DIR / "build" +# Windows binary download URLs +FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" +SEVENZIP_URL = "https://7-zip.org/a/7zr.exe" + def clean(): """Remove previous build artifacts.""" @@ -31,6 +38,73 @@ def clean(): shutil.rmtree(d) +def download_windows_binaries(): + """Download ffmpeg and 7z binaries for Windows builds.""" + if platform.system() != "Windows": + return + + bin_dir = ROOT_DIR / "resource" / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + + # --- ffmpeg --- + ffmpeg_exe = bin_dir / "ffmpeg.exe" + if not ffmpeg_exe.exists(): + print("Downloading ffmpeg...") + data = urllib.request.urlopen(FFMPEG_URL).read() + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for member in zf.namelist(): + name = Path(member).name + if name in ("ffmpeg.exe", "ffprobe.exe"): + print(f" Extracting {name}") + with zf.open(member) as src, open(bin_dir / name, "wb") as dst: + dst.write(src.read()) + print(" ffmpeg ready") + else: + print("ffmpeg already exists, skipping download") + + # --- 7z --- + sevenzip_exe = bin_dir / "7z.exe" + if not sevenzip_exe.exists(): + print("Downloading 7z...") + data = urllib.request.urlopen(SEVENZIP_URL).read() + (bin_dir / "7z.exe").write_bytes(data) + print(" 7z ready") + else: + print("7z already exists, skipping download") + + +def copy_writable_resources_to_dist(): + """Copy writable resource dirs to dist output (alongside the exe). + + These directories need to be writable at runtime: + - resource/bin/: ffmpeg, 7z, Faster-Whisper downloads (Windows only) + - resource/subtitle_style/: user-created subtitle styles (all platforms) + """ + output_dir = DIST_DIR / "VideoCaptioner" + + # bin/ — Windows only (ffmpeg, 7z) + if platform.system() == "Windows": + src = ROOT_DIR / "resource" / "bin" + dst = output_dir / "resource" / "bin" + if src.exists(): + dst.mkdir(parents=True, exist_ok=True) + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, dst / f.name) + print(f" Copied bin/{f.name} to dist") + else: + print("WARNING: resource/bin/ not found, skipping") + + # subtitle_style/ — all platforms (preset + user styles) + src = ROOT_DIR / "resource" / "subtitle_style" + dst = output_dir / "resource" / "subtitle_style" + if src.exists(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + print(" Copied subtitle_style/ to dist") + + def build(): """Run PyInstaller with the spec file.""" if not SPEC_FILE.exists(): @@ -58,6 +132,9 @@ def build(): print("\nBuild FAILED!") sys.exit(1) + # Copy writable resource dirs to dist output + copy_writable_resources_to_dist() + # Print output location output_dir = DIST_DIR / "VideoCaptioner" if platform.system() == "Darwin": @@ -112,6 +189,16 @@ def verify(): else: print("All expected resources found in bundle.") + # Check Windows binaries + if platform.system() == "Windows": + bin_dir = output_dir / "resource" / "bin" + for name in ["ffmpeg.exe", "7z.exe"]: + p = bin_dir / name + if p.exists(): + print(f" {name}: {p.stat().st_size / (1024*1024):.1f} MB") + else: + print(f" WARNING: {name} not found in dist") + print(f"\nExecutable size: {exe.stat().st_size / (1024*1024):.1f} MB") @@ -128,6 +215,9 @@ def main(): if args.clean: clean() + # Download platform-specific binaries before building + download_windows_binaries() + build() verify()