/** * 构建机专用:下载 Windows 嵌入式 Python + FFmpeg,安装 requirements.txt。 * 产物写入仓库根目录 python/ 与 tools/ffmpeg/,随 Electron 打包分发;最终用户无需联网。 */ import fs from 'fs' import path from 'path' import { spawnSync } from 'child_process' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const electronRoot = path.resolve(__dirname, '..') const backendRoot = path.resolve(electronRoot, '..') const PY_VER = '3.12.8' const PYTHON_ZIP = `https://www.python.org/ftp/python/${PY_VER}/python-${PY_VER}-embed-amd64.zip` const GET_PIP = 'https://bootstrap.pypa.io/get-pip.py' /** BtbN 静态构建,单 zip 内含 bin/ffmpeg.exe */ const FFMPEG_ZIP = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' const runtimeDir = path.join(backendRoot, 'python') const embedExe = path.join(runtimeDir, 'python.exe') const toolsFfmpegBin = path.join(backendRoot, 'tools', 'ffmpeg', 'bin') const ffmpegExe = path.join(toolsFfmpegBin, 'ffmpeg.exe') const requirementsTxt = path.join(backendRoot, 'requirements.txt') const tmpDir = path.join(electronRoot, '.tmp-prepare-runtime') function rmrf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }) } function downloadFile(url, dest) { fs.mkdirSync(path.dirname(dest), { recursive: true }) const u = url.replace(/'/g, "''") const d = dest.replace(/'/g, "''") const r = spawnSync( 'powershell.exe', [ '-NoProfile', '-Command', `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '${u}' -OutFile '${d}' -UseBasicParsing`, ], { stdio: 'inherit', windowsHide: true }, ) if (r.status !== 0) throw new Error(`下载失败: ${url}`) } function unzipWithPowerShell(zipPath, destDir) { fs.mkdirSync(destDir, { recursive: true }) const ps = spawnSync( 'powershell.exe', [ '-NoProfile', '-Command', `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`, ], { stdio: 'inherit', windowsHide: true }, ) if (ps.status !== 0) throw new Error('Expand-Archive failed') } function enableEmbedSitePackages() { const pthFiles = fs.readdirSync(runtimeDir).filter((f) => f.endsWith('._pth')) for (const f of pthFiles) { const p = path.join(runtimeDir, f) const base = f.replace(/\._pth$/, '') const zipLine = `${base}.zip` // 嵌入式解释器必须显式启用 import site,Lib\\site-packages 才会进 sys.path(pip 才能用) fs.writeFileSync(p, `${zipLine}\n.\n..\n\nimport site\n`, 'utf8') } } function hasWorkingEmbedPython() { if (!fs.existsSync(embedExe)) return false if (fs.existsSync(path.join(runtimeDir, 'pyvenv.cfg'))) return false if (fs.existsSync(path.join(runtimeDir, 'Scripts', 'python.exe'))) return false enableEmbedSitePackages() const r = spawnSync( embedExe, [ '-c', 'import importlib.util,fastapi,uvicorn,requests,psutil,src; assert importlib.util.find_spec("web2")', ], { cwd: backendRoot, encoding: 'utf8', windowsHide: true, timeout: 60000, }, ) return r.status === 0 } function hasFfmpeg() { return fs.existsSync(ffmpegExe) } function installEmbeddablePython() { console.log('[prepare:runtime] 安装嵌入式 Python ' + PY_VER + ' …') rmrf(tmpDir) fs.mkdirSync(tmpDir, { recursive: true }) const zipPath = path.join(tmpDir, 'python-embed.zip') downloadFile(PYTHON_ZIP, zipPath) rmrf(runtimeDir) fs.mkdirSync(runtimeDir, { recursive: true }) unzipWithPowerShell(zipPath, runtimeDir) enableEmbedSitePackages() const getPipPath = path.join(tmpDir, 'get-pip.py') downloadFile(GET_PIP, getPipPath) let pip = spawnSync(embedExe, [getPipPath, '--no-warn-script-location'], { cwd: runtimeDir, stdio: 'inherit', windowsHide: true, env: { ...process.env, PYTHONPATH: '' }, }) if (pip.status !== 0) throw new Error('get-pip failed') pip = spawnSync(embedExe, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], { cwd: backendRoot, stdio: 'inherit', windowsHide: true, }) if (pip.status !== 0) throw new Error('pip upgrade failed') if (!fs.existsSync(requirementsTxt)) throw new Error('Missing requirements.txt') pip = spawnSync(embedExe, ['-m', 'pip', 'install', '-r', requirementsTxt], { cwd: backendRoot, stdio: 'inherit', windowsHide: true, }) if (pip.status !== 0) throw new Error('pip install -r requirements.txt failed') console.log('[prepare:runtime] 嵌入式 Python + 依赖已就绪。') } function installFfmpeg() { console.log('[prepare:runtime] 下载 FFmpeg(win64)…') rmrf(tmpDir) fs.mkdirSync(tmpDir, { recursive: true }) const zipPath = path.join(tmpDir, 'ffmpeg.zip') downloadFile(FFMPEG_ZIP, zipPath) const extractRoot = path.join(tmpDir, 'ff') unzipWithPowerShell(zipPath, extractRoot) const dirs = fs.readdirSync(extractRoot) const top = dirs.length === 1 ? path.join(extractRoot, dirs[0]) : extractRoot const binSrc = path.join(top, 'bin') if (!fs.existsSync(path.join(binSrc, 'ffmpeg.exe'))) { throw new Error('ffmpeg zip layout unexpected: missing bin/ffmpeg.exe') } rmrf(path.join(backendRoot, 'tools', 'ffmpeg')) fs.mkdirSync(toolsFfmpegBin, { recursive: true }) for (const name of fs.readdirSync(binSrc)) { fs.copyFileSync(path.join(binSrc, name), path.join(toolsFfmpegBin, name)) } console.log('[prepare:runtime] FFmpeg 已安装到 tools/ffmpeg/bin/') } try { if (!hasWorkingEmbedPython()) { if (fs.existsSync(path.join(runtimeDir, 'pyvenv.cfg'))) { console.log('[prepare:runtime] 移除旧 venv,改用嵌入式 Python …') rmrf(runtimeDir) } installEmbeddablePython() } else { console.log('[prepare:runtime] 已存在可用的嵌入式 Python,跳过下载。') } if (!hasFfmpeg()) { installFfmpeg() } else { console.log('[prepare:runtime] 已存在 tools/ffmpeg/bin/ffmpeg.exe,跳过下载。') } rmrf(tmpDir) console.log('[prepare:runtime] 全部完成。') } catch (e) { console.error('[prepare:runtime] 失败:', e) process.exit(1) }