Skip to content

Commit 7bf0062

Browse files
committed
Package native installers with bundled ffmpeg
1 parent 970a2e7 commit 7bf0062

10 files changed

Lines changed: 201 additions & 57 deletions

File tree

.github/workflows/build-and-release.yml

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ jobs:
2222
include:
2323
- os: windows-latest
2424
artifact_name: VideoMergingTool-windows-x64
25-
binary_name: VideoMergingTool.exe
25+
package_path: dist/installer/VideoMergingTool-Setup.exe
2626
- os: macos-latest
2727
artifact_name: VideoMergingTool-macos-arm64
28-
binary_name: VideoMergingTool.app
28+
package_path: dist/VideoMergingTool.dmg
2929
- os: ubuntu-latest
3030
artifact_name: VideoMergingTool-linux-x64
31-
binary_name: VideoMergingTool
31+
package_path: dist/VideoMergingTool
3232

3333
steps:
3434
- name: Checkout
@@ -42,58 +42,33 @@ jobs:
4242
- name: Install build dependencies
4343
run: python -m pip install -r requirements-build.txt
4444

45+
- name: Install Inno Setup on Windows
46+
if: runner.os == 'Windows'
47+
run: choco install innosetup --no-progress -y
48+
4549
- name: Build executable on Windows
4650
if: runner.os == 'Windows'
47-
run: pyinstaller --onefile --windowed --clean --noconfirm --name VideoMergingTool --icon assets/icons/VideoMergingTool.ico --collect-all typer --collect-all click --collect-all rich --collect-all webview --collect-all certifi --hidden-import videomerge.gui --hidden-import tkinter main.py
51+
shell: pwsh
52+
run: .\scripts\build_windows.ps1
4853

4954
- name: Build app on macOS
5055
if: runner.os == 'macOS'
51-
run: pyinstaller --windowed --clean --noconfirm --name VideoMergingTool --icon assets/icons/VideoMergingTool.icns --collect-all typer --collect-all click --collect-all rich --collect-all webview --collect-all certifi --hidden-import videomerge.gui --hidden-import tkinter main.py
56+
run: bash scripts/build_local.sh
5257

5358
- name: Build executable on Linux
5459
if: runner.os == 'Linux'
55-
run: pyinstaller --onefile --windowed --clean --noconfirm --name VideoMergingTool --icon assets/icons/VideoMergingTool.png --collect-all typer --collect-all click --collect-all rich --collect-all webview --collect-all certifi --hidden-import videomerge.gui --hidden-import tkinter main.py
56-
57-
- name: Prepare artifact on Windows
58-
if: runner.os == 'Windows'
59-
shell: pwsh
60-
run: |
61-
New-Item -ItemType Directory -Force package
62-
Copy-Item "dist\${{ matrix.binary_name }}" "package\${{ matrix.binary_name }}"
63-
Copy-Item README.md package\
64-
Compress-Archive -Path package\* -DestinationPath "${{ matrix.artifact_name }}.zip" -Force
65-
66-
- name: Prepare artifact on Unix
67-
if: runner.os == 'Linux'
68-
shell: bash
69-
run: |
70-
mkdir -p package
71-
cp "dist/${{ matrix.binary_name }}" "package/${{ matrix.binary_name }}"
72-
cp README.md package/
73-
tar -czf "${{ matrix.artifact_name }}.tar.gz" -C package .
74-
75-
- name: Prepare artifact on macOS
76-
if: runner.os == 'macOS'
77-
shell: bash
78-
run: |
79-
hdiutil create -volname VideoMergingTool -srcfolder "dist/VideoMergingTool.app" -ov -format UDZO "${{ matrix.artifact_name }}.dmg"
60+
run: bash scripts/build_local.sh
8061

8162
- name: Upload build artifact
8263
uses: actions/upload-artifact@v4
8364
with:
8465
name: ${{ matrix.artifact_name }}
85-
path: |
86-
*.zip
87-
*.tar.gz
88-
*.dmg
66+
path: ${{ matrix.package_path }}
8967
if-no-files-found: error
9068

9169
- name: Upload release asset
9270
if: startsWith(github.ref, 'refs/tags/v')
9371
uses: softprops/action-gh-release@v2
9472
with:
95-
files: |
96-
*.zip
97-
*.tar.gz
98-
*.dmg
73+
files: ${{ matrix.package_path }}
9974
generate_release_notes: true

README.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
本地批量视频智能合并工具。当前版本提供可点击启动的桌面 GUI,同时保留 CLI;内部模块按扫描、探测、分组、转码、合并拆分。
66

7-
底层依赖 FFmpeg / FFprobe。程序启动时会优先查找已下载的本地二进制和系统已有二进制;缺失时默认尝试下载安装到可写目录,避免普通用户手动配置环境变量
7+
桌面安装包内置精简 FFmpeg / FFprobe 二进制,普通用户安装后即可使用,不需要手动配置环境变量
88

99
## 功能
1010

@@ -22,11 +22,11 @@
2222

2323
## 普通用户用法
2424

25-
推荐直接下载 GitHub Release 里的安装包或压缩包
25+
推荐直接下载 GitHub Release 里的安装包
2626

27-
- Windows:运行 `VideoMergingTool.exe`,或使用 `VideoMergingTool-Setup.exe` 安装后从开始菜单/桌面图标启动。
27+
- Windows:运行 `VideoMergingTool-Setup.exe` 安装后从开始菜单/桌面图标启动。
2828
- macOS:打开 `VideoMergingTool.dmg`,将 `VideoMergingTool.app` 拖入 Applications 后从图标启动。
29-
- Linux:解压后运行 `VideoMergingTool`
29+
- Linux:运行 `VideoMergingTool`
3030

3131
桌面应用和安装包会使用 `assets/icons/VideoMergingTool.*` 中的应用图标。
3232

@@ -44,7 +44,7 @@ macOS / Linux CLI 示例:
4444
./VideoMergingTool merge ~/Videos --mode fast
4545
```
4646

47-
如果当前机器没有 FFmpeg / FFprobe,程序会默认尝试自动下载。源码运行时下载到当前运行目录的 `.tools/ffmpeg`;打包后的桌面应用会下载到用户可写的应用数据目录,避免 macOS app 只读目录报错
47+
源码运行时仍会在缺少 FFmpeg / FFprobe 时尝试下载到当前运行目录的 `.tools/ffmpeg`。打包后的桌面应用优先使用应用内置的 FFmpeg / FFprobe
4848

4949
## 开发者运行方式
5050

@@ -164,21 +164,22 @@ Extreme 模式对全部文件选择一个最终画布和编码策略,消解 ro
164164

165165
如果文件已存在且未传 `--overwrite`,会自动追加 `_1``_2` 等后缀。
166166

167-
## 依赖自动检测和下载
167+
## FFmpeg 检测
168168

169169
默认行为:
170170

171-
1. 查找本地工具目录。源码运行时是 `./.tools/ffmpeg`,打包应用是用户应用数据目录。
172-
2. 查找系统 `PATH`
173-
3. 如果缺失,尝试下载静态 FFmpeg/FFprobe。源码运行时使用 `./.tools/ffmpeg`,打包应用使用用户应用数据目录。
171+
1. 打包应用优先使用应用内置的 `ffmpeg` / `ffprobe`
172+
2. 源码运行时查找本地工具目录 `./.tools/ffmpeg`
173+
3. 查找系统 `PATH` 和常见安装路径
174+
4. 源码运行时如果仍缺失,尝试下载静态 FFmpeg / FFprobe
174175

175176
自动下载地址按平台选择:
176177

177178
- macOS: evermeet.cx FFmpeg builds
178179
- Windows: gyan.dev FFmpeg essentials build
179180
- Linux: johnvansickle.com static build
180181

181-
如果网络不可用或下载源不可达,可以手动安装 FFmpeg,或用 `--ffmpeg-path``--ffprobe-path` 指定二进制路径。
182+
如果源码运行时网络不可用或下载源不可达,可以手动安装 FFmpeg,或用 `--ffmpeg-path``--ffprobe-path` 指定二进制路径。
182183

183184
## 本地打包为独立可执行文件
184185

@@ -192,7 +193,7 @@ Windows PowerShell:
192193

193194
```text
194195
dist\VideoMergingTool.exe
195-
dist\installer\VideoMergingTool-Setup.exe # 已安装 Inno Setup 时生成
196+
dist\installer\VideoMergingTool-Setup.exe
196197
```
197198

198199
macOS / Linux:
@@ -220,7 +221,7 @@ dist/VideoMergingTool # Linux
220221
自动行为:
221222

222223
- 推送到 `main`:自动构建 Windows、macOS、Linux 三个平台的可执行文件,并上传到 Actions Artifacts
223-
- 推送版本 tag,例如 `v0.1.0`:自动构建三个平台,并创建 GitHub Release,附带压缩包
224+
- 推送版本 tag,例如 `v0.1.0`:自动构建三个平台,并创建 GitHub Release,附带 `.dmg`、Windows 安装 `.exe` 和 Linux 可执行文件
224225
- 手动触发 workflow:可在 GitHub Actions 页面点击 `Run workflow`
225226

226227
发布新版本:
@@ -232,9 +233,9 @@ git push origin v0.1.0
232233

233234
发布后,用户可以在 GitHub 仓库的 Releases 页面下载:
234235

235-
- `VideoMergingTool-windows-x64.zip`
236-
- `VideoMergingTool-macos-arm64.tar.gz`
237-
- `VideoMergingTool-linux-x64.tar.gz`
236+
- `VideoMergingTool-Setup.exe`
237+
- `VideoMergingTool.dmg`
238+
- `VideoMergingTool`
238239

239240
## 平台说明
240241

assets/icons/VideoMergingTool.icns

-117 KB
Binary file not shown.

assets/icons/VideoMergingTool.ico

-3.62 KB
Binary file not shown.

scripts/build_icons.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
import struct
4+
import subprocess
5+
import tempfile
6+
from pathlib import Path
7+
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
SOURCE = ROOT / "assets/icons/VideoMergingTool.png"
11+
ICNS = ROOT / "assets/icons/VideoMergingTool.icns"
12+
ICO = ROOT / "assets/icons/VideoMergingTool.ico"
13+
14+
15+
def main() -> None:
16+
with tempfile.TemporaryDirectory() as temp_dir:
17+
temp = Path(temp_dir)
18+
icns_chunks = []
19+
for size, chunk_type in (
20+
(16, b"icp4"),
21+
(32, b"icp5"),
22+
(64, b"icp6"),
23+
(128, b"ic07"),
24+
(256, b"ic08"),
25+
(512, b"ic09"),
26+
(1024, b"ic10"),
27+
):
28+
data = _png_at(size, temp / f"icns_{size}.png")
29+
icns_chunks.append(chunk_type + struct.pack(">I", len(data) + 8) + data)
30+
ICNS.write_bytes(b"icns" + struct.pack(">I", 8 + sum(len(chunk) for chunk in icns_chunks)) + b"".join(icns_chunks))
31+
32+
ico_blobs = [(size, _png_at(size, temp / f"ico_{size}.png")) for size in (16, 24, 32, 48, 64, 128, 256)]
33+
entries = []
34+
offset = 6 + 16 * len(ico_blobs)
35+
for size, data in ico_blobs:
36+
width = 0 if size >= 256 else size
37+
height = 0 if size >= 256 else size
38+
entries.append(struct.pack("<BBBBHHII", width, height, 0, 0, 1, 32, len(data), offset))
39+
offset += len(data)
40+
with ICO.open("wb") as file:
41+
file.write(struct.pack("<HHH", 0, 1, len(ico_blobs)))
42+
for entry in entries:
43+
file.write(entry)
44+
for _, data in ico_blobs:
45+
file.write(data)
46+
47+
48+
def _png_at(size: int, output: Path) -> bytes:
49+
subprocess.run(
50+
[
51+
"ffmpeg",
52+
"-y",
53+
"-hide_banner",
54+
"-loglevel",
55+
"error",
56+
"-i",
57+
str(SOURCE),
58+
"-vf",
59+
f"scale={size}:{size}",
60+
"-pix_fmt",
61+
"rgba",
62+
str(output),
63+
],
64+
check=True,
65+
)
66+
return output.read_bytes()
67+
68+
69+
if __name__ == "__main__":
70+
main()

scripts/build_local.sh

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ NAME="${1:-VideoMergingTool}"
55
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
66
ICON_PNG="$PROJECT_ROOT/assets/icons/VideoMergingTool.png"
77
ICON_ICNS="$PROJECT_ROOT/assets/icons/VideoMergingTool.icns"
8+
VENDOR_FFMPEG_DIR="$PROJECT_ROOT/build/vendor/ffmpeg"
89
cd "$PROJECT_ROOT"
910

1011
python3 -m venv .venv
@@ -13,6 +14,8 @@ python3 -m venv .venv
1314

1415
rm -rf build
1516

17+
./.venv/bin/python scripts/prepare_ffmpeg.py --output "$VENDOR_FFMPEG_DIR" --force
18+
1619
PYINSTALLER_ARGS=(
1720
--clean
1821
--noconfirm
@@ -27,18 +30,34 @@ PYINSTALLER_ARGS=(
2730
)
2831

2932
if [[ "$(uname -s)" == "Darwin" ]]; then
30-
PYINSTALLER_ARGS+=(--windowed --icon "$ICON_ICNS")
33+
PYINSTALLER_ARGS+=(
34+
--windowed
35+
--icon "$ICON_ICNS"
36+
--add-binary "$VENDOR_FFMPEG_DIR/ffmpeg:ffmpeg"
37+
--add-binary "$VENDOR_FFMPEG_DIR/ffprobe:ffmpeg"
38+
)
3139
else
32-
PYINSTALLER_ARGS+=(--onefile --windowed --icon "$ICON_PNG")
40+
PYINSTALLER_ARGS+=(
41+
--onefile
42+
--windowed
43+
--icon "$ICON_PNG"
44+
--add-binary "$VENDOR_FFMPEG_DIR/ffmpeg:ffmpeg"
45+
--add-binary "$VENDOR_FFMPEG_DIR/ffprobe:ffmpeg"
46+
)
3347
fi
3448

3549
./.venv/bin/pyinstaller "${PYINSTALLER_ARGS[@]}" main.py
3650

3751
echo
3852
if [[ "$(uname -s)" == "Darwin" && -d "$PROJECT_ROOT/dist/$NAME.app" ]]; then
3953
DMG_PATH="$PROJECT_ROOT/dist/$NAME.dmg"
54+
DMG_ROOT="$PROJECT_ROOT/build/dmg-root"
4055
rm -f "$DMG_PATH"
41-
hdiutil create -volname "$NAME" -srcfolder "$PROJECT_ROOT/dist/$NAME.app" -ov -format UDZO "$DMG_PATH"
56+
rm -rf "$DMG_ROOT"
57+
mkdir -p "$DMG_ROOT"
58+
cp -R "$PROJECT_ROOT/dist/$NAME.app" "$DMG_ROOT/"
59+
ln -s /Applications "$DMG_ROOT/Applications"
60+
hdiutil create -volname "$NAME" -srcfolder "$DMG_ROOT" -ov -format UDZO "$DMG_PATH"
4261
echo "Build complete: $PROJECT_ROOT/dist/$NAME.app"
4362
echo "Installer image: $DMG_PATH"
4463
else

scripts/build_windows.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ $ErrorActionPreference = "Stop"
77
$ProjectRoot = Resolve-Path "$PSScriptRoot\.."
88
Set-Location $ProjectRoot
99
$IconPath = Join-Path $ProjectRoot "assets\icons\VideoMergingTool.ico"
10+
$VendorFfmpegDir = Join-Path $ProjectRoot "build\vendor\ffmpeg"
1011

1112
if (-not (Test-Path ".venv")) {
1213
python -m venv .venv
@@ -19,6 +20,8 @@ if (Test-Path "build") {
1920
Remove-Item -Recurse -Force "build"
2021
}
2122

23+
& ".\.venv\Scripts\python.exe" "scripts\prepare_ffmpeg.py" --output $VendorFfmpegDir --force
24+
2225
& ".\.venv\Scripts\pyinstaller.exe" `
2326
--onefile `
2427
--windowed `
@@ -33,6 +36,8 @@ if (Test-Path "build") {
3336
--collect-all certifi `
3437
--hidden-import videomerge.gui `
3538
--hidden-import tkinter `
39+
--add-binary "$VendorFfmpegDir\ffmpeg.exe;ffmpeg" `
40+
--add-binary "$VendorFfmpegDir\ffprobe.exe;ffmpeg" `
3641
main.py
3742

3843
Write-Host ""

scripts/prepare_ffmpeg.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import logging
5+
import shutil
6+
import sys
7+
from pathlib import Path
8+
9+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10+
11+
from videomerge.env_check import download_ffmpeg_tools
12+
13+
14+
def main() -> None:
15+
parser = argparse.ArgumentParser(description="Download the minimal FFmpeg tools bundled with installers.")
16+
parser.add_argument("--output", type=Path, default=Path("build/vendor/ffmpeg"))
17+
parser.add_argument("--force", action="store_true")
18+
args = parser.parse_args()
19+
20+
if args.force and args.output.exists():
21+
shutil.rmtree(args.output)
22+
23+
logger = logging.getLogger("prepare-ffmpeg")
24+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
25+
tools = download_ffmpeg_tools(args.output, logger)
26+
27+
for path in args.output.iterdir():
28+
if path.name not in {tools.ffmpeg.name, tools.ffprobe.name}:
29+
if path.is_dir():
30+
shutil.rmtree(path)
31+
else:
32+
path.unlink()
33+
34+
print(f"Bundled ffmpeg: {tools.ffmpeg}")
35+
print(f"Bundled ffprobe: {tools.ffprobe}")
36+
37+
38+
if __name__ == "__main__":
39+
main()

tests/test_env_check.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ def test_find_binary_uses_candidate_paths_when_path_lookup_fails(self) -> None:
2222

2323
self.assertEqual(found, binary)
2424

25+
def test_find_binary_prefers_bundled_binary_in_frozen_app(self) -> None:
26+
with tempfile.TemporaryDirectory() as temp_dir:
27+
bundled = Path(temp_dir) / "ffmpeg" / "ffmpeg"
28+
bundled.parent.mkdir()
29+
bundled.touch()
30+
31+
with patch("videomerge.env_check.sys.frozen", True, create=True), patch(
32+
"videomerge.env_check.sys._MEIPASS",
33+
temp_dir,
34+
create=True,
35+
), patch("videomerge.env_check.shutil.which", return_value=None), patch(
36+
"videomerge.env_check._system_binary_candidates",
37+
return_value=[],
38+
):
39+
found = _find_binary("ffmpeg", Path(temp_dir) / "missing-tools")
40+
41+
self.assertEqual(found, bundled)
42+
2543

2644
if __name__ == "__main__":
2745
unittest.main()

0 commit comments

Comments
 (0)