diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..fbad9a6c --- /dev/null +++ b/.github/workflows/build.yml @@ -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) + " + + - 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 diff --git a/VideoCaptioner.spec b/VideoCaptioner.spec new file mode 100644 index 00000000..e2fc3aa2 --- /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 / "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, + }, + ) diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..aa235c3e --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,279 @@ +#!/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 io +import platform +import shutil +import subprocess +import sys +import time +import urllib.request +import zipfile +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" + +# Windows binary download URLs +# essentials build: includes libx264, libvpx, libass — enough for this project (~80MB vs ~190MB full) +FFMPEG_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" +SEVENZIP_URL = "https://7-zip.org/a/7zr.exe" + + +def clean(): + """Remove previous build artifacts.""" + for d in [DIST_DIR, BUILD_DIR]: + if d.exists(): + print(f"Removing {d}") + shutil.rmtree(d) + + +def _download_with_retry(url: str, max_retries: int = 3) -> bytes: + """Download a URL with retry logic.""" + for attempt in range(1, max_retries + 1): + try: + return urllib.request.urlopen(url, timeout=60).read() + except Exception as e: + if attempt < max_retries: + wait = attempt * 5 + print(f" Attempt {attempt} failed: {e}, retrying in {wait}s...") + time.sleep(wait) + else: + raise + + +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...") + try: + data = _download_with_retry(FFMPEG_URL) + 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") + except Exception as e: + print(f" WARNING: Failed to download ffmpeg: {e}") + else: + print("ffmpeg already exists, skipping download") + + # --- 7z --- + sevenzip_exe = bin_dir / "7z.exe" + if not sevenzip_exe.exists(): + print("Downloading 7z...") + try: + data = _download_with_retry(SEVENZIP_URL) + (bin_dir / "7z.exe").write_bytes(data) + print(" 7z ready") + except Exception as e: + print(f" WARNING: Failed to download 7z: {e}") + else: + print("7z already exists, skipping download") + + +def copy_writable_resources_to_dist(): + """Copy writable resource dirs to dist output (alongside the exe). + + Only resource/bin/ (Windows) needs to be in the program directory. + User data (settings, models, subtitle styles) goes to system data dir. + """ + 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 is bundled inside _internal/ and copied to user data dir + # on first run by config.py — no need to copy to dist/ + + +def ensure_version_file(): + """Generate videocaptioner/_version.py if it doesn't exist. + + hatch-vcs generates this file during pip install, but it may not exist + when building directly with PyInstaller. + """ + version_file = ROOT_DIR / "videocaptioner" / "_version.py" + if version_file.exists(): + print(f"Version file exists: {version_file}") + return + + # Try to get version from git tags via hatch-vcs + try: + result = subprocess.run( + [sys.executable, "-m", "hatchling", "version"], + capture_output=True, text=True, cwd=str(ROOT_DIR), + ) + if result.returncode == 0: + version = result.stdout.strip() + else: + # Fallback: get version from git describe + result = subprocess.run( + ["git", "describe", "--tags", "--always"], + capture_output=True, text=True, cwd=str(ROOT_DIR), + ) + version = result.stdout.strip().lstrip("v") if result.returncode == 0 else "0.0.0" + except Exception: + version = "0.0.0" + + version_file.write_text( + f'__version__ = "{version}"\n', + encoding="utf-8", + ) + print(f"Generated {version_file} with version {version}") + + +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) + + # Copy writable resource dirs to dist output + copy_writable_resources_to_dist() + + # 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/NotoSansSC-Regular.ttf", + "resource/subtitle_style/ass-default.json", + "resource/subtitle_style/rounded-default.json", + "resource/translations", + "videocaptioner/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.") + + # 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") + + +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() + + # Pre-build steps + ensure_version_file() + download_windows_binaries() + + build() + verify() + + +if __name__ == "__main__": + main() diff --git a/videocaptioner/config.py b/videocaptioner/config.py index c0535ede..47685f91 100644 --- a/videocaptioner/config.py +++ b/videocaptioner/config.py @@ -1,13 +1,46 @@ +""" +VideoCaptioner 路径与配置模块 + +支持三种运行模式: + 1. PyInstaller 打包模式 (sys.frozen=True) + 2. 源码开发模式 (项目根目录存在 resource/) + 3. pip 安装模式 (通过 platformdirs 定位数据目录) + +目录职责划分: + 程序目录 (frozen: exe 旁, 开发: 项目根): + - resource/bin/ → ffmpeg, 7z, Faster-Whisper 等二进制工具 (仅 frozen/开发) + + 只读资源 (RESOURCE_PATH): + - assets/ → logo、背景图、QSS 样式 + - fonts/ → 内置字体 + - translations/ → 国际化 .qm 文件 + + 用户数据 (APPDATA_PATH, 系统标准目录, 升级不受影响): + - settings.json → 用户设置 + API Key + - logs/ → 应用日志 + - cache/ → LLM/ASR/翻译缓存 + - models/ → ASR 模型 (默认位置, 可在设置中自定义) + - resource/subtitle_style/ → 用户自定义字幕样式 + + 工作目录 (WORK_PATH): + - frozen: exe 旁的 work-dir/ + - 开发: 项目根下 work-dir/ + - pip: ~/VideoCaptioner/ +""" + import logging import os +import sys from pathlib import Path +# ── 版本号 ────────────────────────────────────────────────────────────────── try: from videocaptioner._version import __version__ as _raw_version # Strip dev suffix (e.g. "1.5.0.dev103+g38544177c" → "1.5.0") VERSION = _raw_version.split(".dev")[0] except Exception: VERSION = "0.0.0-dev" + YEAR = 2026 APP_NAME = "VideoCaptioner" AUTHOR = "Weifeng" @@ -17,51 +50,78 @@ RELEASE_URL = "https://github.com/WEIFENG2333/VideoCaptioner/releases/latest" FEEDBACK_URL = "https://github.com/WEIFENG2333/VideoCaptioner/issues" -# Detect whether running from source tree or pip-installed -_PACKAGE_DIR = Path(__file__).parent -_PROJECT_ROOT = _PACKAGE_DIR.parent - -# Development mode: resource/ exists next to the package -_IS_DEV = (_PROJECT_ROOT / "resource").is_dir() +# ── 基础路径检测 ──────────────────────────────────────────────────────────── +_PACKAGE_DIR = Path(__file__).parent # videocaptioner/ +_PROJECT_ROOT = _PACKAGE_DIR.parent # 项目根目录 +_IS_DEV = (_PROJECT_ROOT / "resource").is_dir() and not getattr(sys, "frozen", False) +# ── 用户数据目录 (三种模式统一逻辑) ──────────────────────────────────────── +# 开发模式: 项目根/AppData (方便调试, 不污染系统目录) +# frozen + pip: 系统标准目录 (升级不受影响) +# Windows: %LOCALAPPDATA%/VideoCaptioner/ +# macOS: ~/Library/Application Support/VideoCaptioner/ +# Linux: ~/.local/share/VideoCaptioner/ if _IS_DEV: - ROOT_PATH = _PROJECT_ROOT - RESOURCE_PATH = ROOT_PATH / "resource" - APPDATA_PATH = ROOT_PATH / "AppData" - WORK_PATH = ROOT_PATH / "work-dir" + APPDATA_PATH = _PROJECT_ROOT / "AppData" else: - # Installed via pip — use platform-appropriate directories - from platformdirs import user_data_dir + from platformdirs import user_data_path + APPDATA_PATH = user_data_path(APP_NAME) + +# ── 程序目录 + 只读资源 (因模式而异) ─────────────────────────────────────── +if getattr(sys, "frozen", False): + # ── PyInstaller 打包 ── + _MEIPASS = Path(sys._MEIPASS) # type: ignore[attr-defined] + _EXE_DIR = Path(sys.executable).parent + ROOT_PATH = _EXE_DIR + WORK_PATH = _EXE_DIR / "work-dir" + BIN_PATH = _EXE_DIR / "resource" / "bin" + RESOURCE_PATH = _MEIPASS / "resource" - ROOT_PATH = Path(user_data_dir(APP_NAME)) +elif _IS_DEV: + # ── 源码开发 ── + ROOT_PATH = _PROJECT_ROOT + WORK_PATH = ROOT_PATH / "work-dir" + BIN_PATH = ROOT_PATH / "resource" / "bin" RESOURCE_PATH = ROOT_PATH / "resource" - APPDATA_PATH = ROOT_PATH + +else: + # ── pip 安装 ── + ROOT_PATH = APPDATA_PATH WORK_PATH = Path.home() / "VideoCaptioner" + BIN_PATH = APPDATA_PATH / "bin" + RESOURCE_PATH = APPDATA_PATH / "resource" -BIN_PATH = RESOURCE_PATH / "bin" +# ── 只读资源路径 ──────────────────────────────────────────────────────────── ASSETS_PATH = RESOURCE_PATH / "assets" -SUBTITLE_STYLE_PATH = RESOURCE_PATH / "subtitle_style" -TRANSLATIONS_PATH = RESOURCE_PATH / "translations" FONTS_PATH = RESOURCE_PATH / "fonts" +TRANSLATIONS_PATH = RESOURCE_PATH / "translations" -# Fallback: bundled fonts inside the package (for pip install) +# pip 安装时的字体回退: 包内 resources/fonts/ _BUNDLED_FONTS = _PACKAGE_DIR / "resources" / "fonts" if not FONTS_PATH.exists() and _BUNDLED_FONTS.exists(): FONTS_PATH = _BUNDLED_FONTS +# ── 用户数据路径 ──────────────────────────────────────────────────────────── +SETTINGS_PATH = APPDATA_PATH / "settings.json" LOG_PATH = APPDATA_PATH / "logs" LLM_LOG_FILE = LOG_PATH / "llm_requests.jsonl" -SETTINGS_PATH = APPDATA_PATH / "settings.json" CACHE_PATH = APPDATA_PATH / "cache" MODEL_PATH = APPDATA_PATH / "models" +# 字幕样式: 开发模式直接用 resource/ 下的,其他模式放在用户数据目录 +if _IS_DEV: + SUBTITLE_STYLE_PATH = _PROJECT_ROOT / "resource" / "subtitle_style" +else: + SUBTITLE_STYLE_PATH = APPDATA_PATH / "resource" / "subtitle_style" + +# ── 二进制工具路径 ────────────────────────────────────────────────────────── FASTER_WHISPER_PATH = BIN_PATH / "Faster-Whisper-XXL" -# Logging +# ── 日志配置 ──────────────────────────────────────────────────────────────── LOG_LEVEL = logging.INFO LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -# Add bin paths to PATH (only if they exist) +# ── 环境变量: 将 bin 加入 PATH ───────────────────────────────────────────── if BIN_PATH.exists(): os.environ["PATH"] = str(FASTER_WHISPER_PATH) + os.pathsep + os.environ["PATH"] os.environ["PATH"] = str(BIN_PATH) + os.pathsep + os.environ["PATH"] @@ -69,6 +129,14 @@ if (BIN_PATH / "vlc").exists(): os.environ["PYTHON_VLC_MODULE_PATH"] = str(BIN_PATH / "vlc") -# Create data directories -for p in [CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH]: - p.mkdir(parents=True, exist_ok=True) +# ── 创建必要目录 ──────────────────────────────────────────────────────────── +for _p in [APPDATA_PATH, CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH]: + _p.mkdir(parents=True, exist_ok=True) + +# ── PyInstaller 首次运行: 复制可写预设资源 ────────────────────────────────── +if getattr(sys, "frozen", False): + import shutil + + _bundled_styles = Path(sys._MEIPASS) / "resource" / "subtitle_style" # type: ignore[attr-defined] + if _bundled_styles.exists() and not SUBTITLE_STYLE_PATH.exists(): + shutil.copytree(_bundled_styles, SUBTITLE_STYLE_PATH) diff --git a/videocaptioner/core/subtitle/ass_renderer.py b/videocaptioner/core/subtitle/ass_renderer.py index b023acbd..efc1d830 100644 --- a/videocaptioner/core/subtitle/ass_renderer.py +++ b/videocaptioner/core/subtitle/ass_renderer.py @@ -9,7 +9,7 @@ from PIL import Image -from videocaptioner.config import CACHE_PATH, FONTS_PATH, RESOURCE_PATH +from videocaptioner.config import CACHE_PATH, FONTS_PATH from videocaptioner.core.entities import SubtitleLayoutEnum from videocaptioner.core.utils.logger import setup_logger @@ -155,8 +155,8 @@ def render_ass_preview( # 确保背景图片存在 bg_path_obj = Path(bg_image_path) if not bg_path_obj.exists(): - # 使用默认黑色背景 - default_bg = RESOURCE_PATH / "assets" / "default_bg.png" + # 使用默认黑色背景(生成到可写的 CACHE_PATH) + default_bg = CACHE_PATH / "default_bg.png" if not default_bg.exists(): default_bg.parent.mkdir(parents=True, exist_ok=True) # 生成黑色背景 diff --git a/videocaptioner/ui/main.py b/videocaptioner/ui/main.py index f6854b5d..53b34f64 100644 --- a/videocaptioner/ui/main.py +++ b/videocaptioner/ui/main.py @@ -25,10 +25,15 @@ def main(): from videocaptioner.ui.view.main_window import MainWindow # Qt platform plugin path - lib_folder = "Lib" if platform.system() == "Windows" else "lib" - plugin_path = os.path.join( - sys.prefix, lib_folder, "site-packages", "PyQt5", "Qt5", "plugins" - ) + if getattr(sys, "frozen", False): + # PyInstaller: Qt plugins are bundled inside _internal/PyQt5/Qt5/plugins + _internal = os.path.join(os.path.dirname(sys.executable), "_internal") + plugin_path = os.path.join(_internal, "PyQt5", "Qt5", "plugins") + 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 # Logger + global exception hook