Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
137 changes: 137 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Build

on:
push:
branches: [master]
paths:
- "videocaptioner/**"
- "resource/**"
- "VideoCaptioner.spec"
- "scripts/build.py"
- "pyproject.toml"
- ".github/workflows/build.yml"
pull_request:
branches: [master]
paths:
- "videocaptioner/**"
- "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
with:
fetch-depth: 0 # Full history for hatch-vcs version detection

- 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']
gui_deps = data['project']['optional-dependencies']['gui']
subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + deps + gui_deps)
"
Copy link
Copy Markdown

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
}
# 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)
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
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 / "videocaptioner" / "core" / "prompts"), "videocaptioner/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 / "videocaptioner" / "__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.5.0",
"CFBundleShortVersionString": "1.5.0",
"NSHighResolutionCapable": True,
},
)
Loading
Loading