Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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)
"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI ignores platform-specific PyQt5-Qt5 version pin on Windows

Medium Severity

The CI installs dependencies via pip by reading pyproject.toml's [project] dependencies, but the project's [tool.uv] override-dependencies deliberately pins PyQt5-Qt5==5.15.2 on Windows. Since pip doesn't understand uv overrides, the Windows CI build installs the latest PyQt5-Qt5 (currently 5.15.16) instead of the explicitly required 5.15.2. This means the Windows artifact bundles a different Qt binary version than what developers test with, potentially causing runtime issues.

Fix in Cursor Fix in Web


- 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ cookies.txt
htmlcov/
*.log

# PyInstaller 构建产物
/build/
/dist/

# 项目文档
CLAUDE.md

Expand Down
151 changes: 151 additions & 0 deletions VideoCaptioner.spec
Original file line number Diff line number Diff line change
@@ -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,
},
)
15 changes: 12 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import sys
from pathlib import Path

VERSION = "v1.4.0"
Expand All @@ -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"
Expand Down
28 changes: 17 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading