Skip to content
Merged
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
102 changes: 102 additions & 0 deletions .github/workflows/build-mac.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Build VideoMatrix Installer (macOS)

# Self-contained macOS .dmg for the Electron + FastAPI + ffmpeg architecture.
# Same recipe as the Windows workflow, only the platform-specific binaries
# differ. Users mount the dmg and drag-drop into Applications — no Python
# or Homebrew needed.

on:
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to upload the dmg to"
required: true
default: "v2.0.0"
push:
tags:
- "v*"

permissions:
contents: write

jobs:
build:
runs-on: macos-latest

steps:
- name: Checkout
uses: actions/checkout@v4

# ── Python toolchain ─────────────────────────────────────────────────
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install backend deps + PyInstaller
working-directory: backend
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install pyinstaller

# ── Bundle ffmpeg (static mac arm64 + x64 build from evermeet) ────────
- name: Download FFmpeg static build
run: |
mkdir -p backend/ffmpeg
curl -L https://evermeet.cx/ffmpeg/getrelease/zip -o ffmpeg.zip
curl -L https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip -o ffprobe.zip
unzip -o ffmpeg.zip -d backend/ffmpeg
unzip -o ffprobe.zip -d backend/ffmpeg
chmod +x backend/ffmpeg/ffmpeg backend/ffmpeg/ffprobe

# ── Backend → single self-contained binary ───────────────────────────
- name: Build backend binary
working-directory: backend
run: pyinstaller --clean videomatrix-backend.spec

# ── Node toolchain ───────────────────────────────────────────────────
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install frontend deps
working-directory: frontend
run: npm ci

- name: Build renderer / main / preload
working-directory: frontend
run: npm run build

# ── Package as .dmg (extraResources pulls backend exe in automatically)
- name: Package Electron app
working-directory: frontend
env:
GH_TOKEN: ${{ github.token }}
# skip code signing in CI — release will be ad-hoc signed
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: npx electron-builder --mac --publish never

- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: VideoMatrix-mac-dmg
path: frontend/release/*.dmg

- name: Upload to GitHub Release
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${{ github.event.inputs.release_tag }}"
if [ -z "$tag" ]; then tag="${GITHUB_REF_NAME}"; fi
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
--title "VideoMatrix ${tag#v}" \
--notes "Self-contained macOS dmg (Electron + FastAPI + ffmpeg, no Python required)."
fi
dmg=$(ls frontend/release/*.dmg | head -1)
gh release upload "$tag" "$dmg" --repo "$GITHUB_REPOSITORY" --clobber
82 changes: 57 additions & 25 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
name: Build Windows EXE
name: Build VideoMatrix Installer (Windows)

# Builds a self-contained Windows installer (NSIS .exe) for the
# Electron + FastAPI + ffmpeg architecture. Users double-click the
# resulting Setup.exe — no Python required.

on:
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to upload the EXE to"
description: "Release tag to upload the installer to"
required: true
default: "v1.5.1"
default: "v2.0.0"
push:
tags:
- "v*"
Expand All @@ -22,39 +26,66 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# ── Python toolchain ─────────────────────────────────────────────────
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install build tools
run: python -m pip install --upgrade pip pyinstaller
- name: Install backend deps + PyInstaller
shell: pwsh
working-directory: backend
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install pyinstaller

- name: Download FFmpeg
# ── Bundle ffmpeg next to the backend so PyInstaller sweeps it in ────
- name: Download FFmpeg static build
shell: pwsh
run: |
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
Expand-Archive -LiteralPath ffmpeg.zip -DestinationPath ffmpeg -Force
$bin = Get-ChildItem -Path ffmpeg -Recurse -Filter ffmpeg.exe | Select-Object -First 1 -ExpandProperty DirectoryName
"FFMPEG_BIN=$bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Expand-Archive -LiteralPath ffmpeg.zip -DestinationPath ffmpeg-extracted -Force
$bin = Get-ChildItem -Path ffmpeg-extracted -Recurse -Filter ffmpeg.exe | Select-Object -First 1 -ExpandProperty DirectoryName
New-Item -ItemType Directory -Force -Path backend/ffmpeg | Out-Null
Copy-Item "$bin\ffmpeg.exe" backend/ffmpeg/
Copy-Item "$bin\ffprobe.exe" backend/ffmpeg/

- name: Build executable
# ── Backend → single .exe (ffmpeg + uvicorn + fastapi bundled) ───────
- name: Build backend binary
shell: pwsh
run: |
pyinstaller `
--onefile `
--windowed `
--name "VideoMatrix1.5.1" `
--add-binary "$env:FFMPEG_BIN\ffmpeg.exe;." `
--add-binary "$env:FFMPEG_BIN\ffprobe.exe;." `
AutoVideoMatrix1.5.1.py
working-directory: backend
run: pyinstaller --clean videomatrix-backend.spec

# ── Node toolchain ───────────────────────────────────────────────────
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install frontend deps
working-directory: frontend
run: npm ci

- name: Build renderer / main / preload
working-directory: frontend
run: npm run build

# ── Frontend installer (NSIS) — pulls backend exe in via extraResources
- name: Package Electron app
working-directory: frontend
env:
GH_TOKEN: ${{ github.token }}
run: npx electron-builder --win --publish never

- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: VideoMatrix1.5.1-windows
path: dist/VideoMatrix1.5.1.exe
name: VideoMatrix-windows-installer
path: frontend/release/*.exe

- name: Upload to GitHub Release
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
Expand All @@ -63,11 +94,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
$tag = "${{ github.event.inputs.release_tag }}"
if ([string]::IsNullOrWhiteSpace($tag)) {
$tag = "${{ github.ref_name }}"
}
if ([string]::IsNullOrWhiteSpace($tag)) { $tag = "${{ github.ref_name }}" }
gh release view $tag --repo $env:GITHUB_REPOSITORY 2>$null
if ($LASTEXITCODE -ne 0) {
gh release create $tag --repo $env:GITHUB_REPOSITORY --title "VideoMatrix $($tag.TrimStart('v'))" --notes "Windows build generated by GitHub Actions."
gh release create $tag --repo $env:GITHUB_REPOSITORY `
--title "VideoMatrix $($tag.TrimStart('v'))" `
--notes "Self-contained Windows installer (Electron + FastAPI + ffmpeg, no Python required)."
}
gh release upload $tag "dist/VideoMatrix1.5.1.exe" --repo $env:GITHUB_REPOSITORY --clobber
$installer = Get-ChildItem -Path frontend/release -Filter "*.exe" | Select-Object -First 1
gh release upload $tag $installer.FullName --repo $env:GITHUB_REPOSITORY --clobber
17 changes: 16 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/

# Build outputs
build/
dist/
*.spec
release/
*.exe

# PyInstaller specs are ignored by default; keep the backend one tracked
*.spec
!backend/videomatrix-backend.spec

# Frontend
node_modules/

# Generated runtime files
config.json
usage_history.json
media_cache.json
ffmpeg_error_log.txt
stdout.txt
stderr.txt

# OS / IDE
.DS_Store
Thumbs.db
.idea/
.vscode/
Empty file added backend/app/__init__.py
Empty file.
Empty file added backend/app/api/__init__.py
Empty file.
120 changes: 120 additions & 0 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import os
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import asyncio
import json

from ..models.schemas import (
CreateTaskRequest, CreateTaskResponse, StopTaskRequest,
TaskStatus, ScanRequest, ScanResponse, ProbeResult, VideoConfig
)
from ..services.task_service import task_service
from ..core.ffmpeg import probe_media, extract_media_info

router = APIRouter()


@router.post("/tasks", response_model=CreateTaskResponse)
def create_task(req: CreateTaskRequest):
task_id = task_service.create_task(req.config)
return CreateTaskResponse(task_id=task_id, message="任务已创建")


@router.get("/tasks", response_model=list[TaskStatus])
def list_tasks():
return task_service.get_all_tasks()


@router.get("/tasks/{task_id}", response_model=TaskStatus)
def get_task(task_id: str):
task = task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return task


@router.post("/tasks/{task_id}/stop")
def stop_task(task_id: str):
success = task_service.stop_task(task_id)
if not success:
raise HTTPException(status_code=404, detail="任务不存在")
return {"message": "停止指令已发送"}


@router.get("/tasks/{task_id}/logs")
def get_logs(task_id: str):
task = task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return {"logs": task_service.get_logs(task_id)}


@router.get("/tasks/{task_id}/stream")
async def stream_logs(task_id: str):
task = task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")

async def event_generator():
last_len = 0
while True:
logs = task_service.get_logs(task_id)
if len(logs) > last_len:
new_logs = logs[last_len:]
for line in new_logs:
yield f"data: {json.dumps({'log': line}, ensure_ascii=False)}\n\n"
last_len = len(logs)

current = task_service.get_task(task_id)
if current and current.status in ("completed", "failed", "stopped"):
yield f"data: {json.dumps({'status': current.status, 'progress': current.progress}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
break

await asyncio.sleep(0.5)

return StreamingResponse(
event_generator(),
media_type="text/event-stream"
)


@router.post("/scan", response_model=ScanResponse)
def scan_directory(req: ScanRequest):
if not os.path.exists(req.dir_path):
raise HTTPException(status_code=400, detail="目录不存在")
files = []
for root, _, filenames in os.walk(req.dir_path):
for f in filenames:
if any(f.lower().endswith(ext) for ext in req.extensions):
files.append(os.path.join(root, f))
return ScanResponse(files=files, count=len(files))


@router.post("/probe", response_model=ProbeResult)
def probe_file(file_path: str):
if not os.path.exists(file_path):
raise HTTPException(status_code=400, detail="文件不存在")
info = probe_media(file_path)
if not info:
raise HTTPException(status_code=500, detail="无法探测文件")
dur, has_audio, width, height, fps = extract_media_info(info, file_path)
return ProbeResult(
file_path=file_path,
duration=dur,
has_audio=has_audio,
width=width,
height=height,
fps=fps
)


@router.post("/benchmark")
def benchmark(config: VideoConfig):
return task_service.get_benchmark(config)


@router.post("/history/clear")
def clear_history():
task_service.clear_history()
return {"message": "使用记录已清除"}
Empty file added backend/app/core/__init__.py
Empty file.
Loading