Merge branch 'dev_package'

This commit is contained in:
puke
2025-11-09 22:12:55 +08:00
10 changed files with 1168 additions and 6 deletions

3
.gitignore vendored
View File

@@ -62,9 +62,6 @@ temp/
tmp/
.cache/
# Packaging tools
packaging/
# MkDocs build output
site/

View File

@@ -22,6 +22,16 @@ Or with custom settings:
uv run python api/app.py --host 0.0.0.0 --port 8080 --reload
"""
import sys
from pathlib import Path
# Add project root to sys.path for module imports
# This ensures imports work correctly in both development and packaged environments
_script_dir = Path(__file__).resolve().parent
_project_root = _script_dir.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
import argparse
from contextlib import asynccontextmanager
from fastapi import FastAPI

216
packaging/windows/README.md Normal file
View File

@@ -0,0 +1,216 @@
# Windows Package Builder
Automated build system for creating Windows portable packages of Pixelle-Video.
## Quick Start
### Prerequisites
- Python 3.11+ (for running the build script)
- PyYAML: `pip install pyyaml`
- Internet connection (for downloading Python, FFmpeg, etc.)
### Build Package
```bash
# Basic build
python packaging/windows/build.py
# Build with China mirrors (faster in China)
python packaging/windows/build.py --cn-mirror
# Custom output directory
python packaging/windows/build.py --output /path/to/output
```
## Configuration
Edit `config/build_config.yaml` to customize:
- Python version
- FFmpeg version
- Excluded files/folders
- Build options
- Mirror settings
## Output
The build process creates:
```
dist/windows/
├── Pixelle-Video-v0.1.0-win64/ # Build directory
│ ├── python/ # Python embedded
│ ├── tools/ # FFmpeg, etc.
│ ├── Pixelle-Video/ # Project files
│ ├── data/ # User data (empty)
│ ├── output/ # Output (empty)
│ ├── start.bat # Main launcher
│ ├── start_api.bat # API launcher
│ ├── start_web.bat # Web launcher
│ └── README.txt # User guide
├── Pixelle-Video-v0.1.0-win64.zip # ZIP package
└── Pixelle-Video-v0.1.0-win64.zip.sha256 # Checksum
```
## Build Process
The builder performs these steps:
1. **Download Phase**
- Python embedded distribution
- FFmpeg portable
- Cached in `.cache/` for reuse
2. **Extract Phase**
- Extract Python to `build/python/`
- Extract FFmpeg to `build/tools/ffmpeg/`
3. **Prepare Phase**
- Enable site-packages in Python
- Install pip
- Install uv (if configured)
4. **Install Phase**
- Install project dependencies using uv/pip
- Pre-install all packages
5. **Copy Phase**
- Copy project files (excluding test/docs/cache)
- Generate launcher scripts from templates
- Create empty directories
6. **Package Phase**
- Create ZIP archive
- Generate SHA256 checksum
## Templates
Launcher script templates in `templates/`:
- `start.bat` - Main Web UI launcher
- `start_api.bat` - API server launcher
- `start_web.bat` - Web UI only launcher
- `README.txt` - User documentation
Templates support placeholders:
- `{VERSION}` - Project version
- `{BUILD_DATE}` - Build timestamp
## Cache
Downloaded files are cached in `.cache/`:
```
.cache/
├── python-3.11.9-embed-amd64.zip
├── ffmpeg-6.1.1-win64.zip
└── get-pip.py
```
Delete cache to force re-download.
## Troubleshooting
### Build fails with "PyYAML not found"
```bash
pip install pyyaml
```
### Downloads are slow
Use China mirrors:
```bash
python build.py --cn-mirror
```
### Dependencies installation fails
Check:
1. Internet connection
2. PyPI mirrors accessibility
3. Project dependencies in `pyproject.toml`
### ZIP creation fails
Ensure:
1. Sufficient disk space
2. Write permissions to output directory
3. No files are locked by other processes
## Advanced Usage
### Custom Configuration
Create custom config file:
```bash
cp config/build_config.yaml config/my_config.yaml
# Edit my_config.yaml
python build.py --config config/my_config.yaml
```
### Skip ZIP Creation
Edit `build_config.yaml`:
```yaml
build:
create_zip: false
```
### Include Chrome Portable
Edit `build_config.yaml`:
```yaml
chrome:
include: true
download_url: "https://path/to/chrome-portable.zip"
```
## Maintenance
### Update Python Version
Edit `config/build_config.yaml`:
```yaml
python:
version: "3.11.10"
download_url: "https://www.python.org/ftp/python/3.11.10/python-3.11.10-embed-amd64.zip"
```
### Update FFmpeg Version
Edit `config/build_config.yaml`:
```yaml
ffmpeg:
version: "6.2.0"
download_url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/..."
```
## Distribution
To distribute the package:
1. Upload ZIP file to release page
2. Include SHA256 checksum for verification
3. Provide installation instructions
Users verify download:
```bash
# Windows PowerShell
Get-FileHash Pixelle-Video-v0.1.0-win64.zip -Algorithm SHA256
```
Compare with `.sha256` file.
## License
Same as Pixelle-Video project license.

691
packaging/windows/build.py Normal file
View File

@@ -0,0 +1,691 @@
#!/usr/bin/env python3
"""
Windows Package Builder for Pixelle-Video
This script automates the creation of a Windows portable package:
1. Downloads Python embedded distribution
2. Downloads FFmpeg portable
3. Prepares Python environment (enable site-packages, install pip)
4. Installs project dependencies
5. Copies project files
6. Generates launcher scripts
7. Creates final ZIP package
Usage:
python build.py [--config CONFIG] [--output OUTPUT] [--cn-mirror]
"""
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlretrieve
try:
import yaml
except ImportError:
print("ERROR: PyYAML is required. Install it with: pip install pyyaml")
sys.exit(1)
class Color:
"""ANSI color codes for terminal output"""
HEADER = '\033[95m'
BLUE = '\033[94m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
RESET = '\033[0m'
BOLD = '\033[1m'
class WindowsPackageBuilder:
"""Build Windows portable package for Pixelle-Video"""
def __init__(self, config_path: str, output_dir: Optional[str] = None, use_cn_mirror: bool = False):
self.config_path = Path(config_path)
self.script_dir = Path(__file__).parent
self.project_root = self.script_dir.parent.parent
# Load configuration
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
# Override mirror setting if specified
if use_cn_mirror:
self.config['mirrors']['use_cn_mirror'] = True
# Setup paths
self.output_dir = Path(output_dir) if output_dir else self.project_root / self.config['build']['output_dir']
self.cache_dir = self.project_root / self.config['cache']['cache_dir']
self.templates_dir = self.script_dir / 'templates'
# Get version from pyproject.toml
self.version = self._read_version()
self.package_name = f"{self.config['package']['name']}-v{self.version}-{self.config['package']['architecture']}"
self.build_dir = self.output_dir / self.package_name
def _read_version(self) -> str:
"""Read version from pyproject.toml"""
pyproject_path = self.project_root / 'pyproject.toml'
try:
import tomllib
except ImportError:
# Python < 3.11 fallback
try:
import tomli as tomllib
except ImportError:
# Simple regex fallback
import re
with open(pyproject_path, 'r') as f:
content = f.read()
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return "0.1.0"
with open(pyproject_path, 'rb') as f:
pyproject = tomllib.load(f)
return pyproject.get('project', {}).get('version', '0.1.0')
def log(self, message: str, level: str = "INFO"):
"""Print colored log message"""
colors = {
"INFO": Color.BLUE,
"SUCCESS": Color.GREEN,
"WARNING": Color.YELLOW,
"ERROR": Color.RED,
"HEADER": Color.HEADER,
}
color = colors.get(level, Color.RESET)
print(f"{color}[{level}]{Color.RESET} {message}")
def download_file(self, url: str, output_path: Path, description: str = "", max_retries: int = 3) -> bool:
"""Download file with progress indication and retry support"""
import ssl
import urllib.request
for attempt in range(max_retries):
try:
if attempt > 0:
self.log(f"Retry {attempt}/{max_retries}...")
self.log(f"Downloading {description or url}...")
# Create SSL context that's more lenient
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
def report_progress(block_num, block_size, total_size):
downloaded = block_num * block_size
percent = min(downloaded / total_size * 100, 100) if total_size > 0 else 0
print(f"\r Progress: {percent:.1f}%", end='', flush=True)
# Try with urllib first
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ssl_context))
urllib.request.install_opener(opener)
urlretrieve(url, output_path, reporthook=report_progress)
print() # New line after progress
self.log(f"Downloaded to {output_path}", "SUCCESS")
return True
except Exception as e:
self.log(f"Download attempt {attempt + 1} failed: {e}", "WARNING")
if attempt < max_retries - 1:
import time
time.sleep(2) # Wait before retry
else:
self.log(f"All download attempts failed", "ERROR")
# Try with curl as fallback
return self._download_with_curl(url, output_path, description)
return False
def _find_suitable_python(self) -> Optional[str]:
"""Find a suitable Python 3.11+ for installing dependencies"""
candidates = [
# Try common locations for newer Python versions
'/Users/puke/miniforge3/bin/python3', # User's conda
'/opt/homebrew/bin/python3', # Homebrew
'/usr/local/bin/python3', # Manual install
]
# Also check what's in PATH
for i in range(11, 14): # Python 3.11, 3.12, 3.13
for py_name in [f'python3.{i}', f'python{i}']:
found = shutil.which(py_name)
if found and found not in candidates:
candidates.append(found)
# Check generic python3
python3_path = shutil.which('python3')
if python3_path and '.venv' not in python3_path:
candidates.append(python3_path)
# Test each candidate
for candidate in candidates:
try:
if not candidate:
continue
# Skip if in project venv
if '.venv' in candidate or 'venv' in candidate:
continue
# Check if path exists
if not os.path.exists(candidate):
continue
# Check Python version
result = subprocess.run(
[candidate, '-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
version = result.stdout.strip()
major, minor = map(int, version.split('.'))
# Need Python 3.11+
if major == 3 and minor >= 11:
# Check if pip is available
pip_check = subprocess.run(
[candidate, '-m', 'pip', '--version'],
capture_output=True,
timeout=5
)
if pip_check.returncode == 0:
self.log(f"Found Python {version} at {candidate}", "SUCCESS")
return candidate
except Exception as e:
continue
return None
def _download_with_curl(self, url: str, output_path: Path, description: str = "") -> bool:
"""Fallback download method using curl"""
try:
self.log(f"Trying curl fallback for {description}...")
result = subprocess.run(
['curl', '-L', '-o', str(output_path), url, '--progress-bar'],
check=True,
capture_output=False
)
if result.returncode == 0 and output_path.exists():
self.log(f"Downloaded with curl to {output_path}", "SUCCESS")
return True
except Exception as e:
self.log(f"Curl download also failed: {e}", "ERROR")
return False
def download_python(self) -> Path:
"""Download Python embedded distribution"""
python_config = self.config['python']
cache_file = self.cache_dir / f"python-{python_config['version']}-embed-amd64.zip"
if cache_file.exists():
self.log(f"Using cached Python: {cache_file}")
return cache_file
self.cache_dir.mkdir(parents=True, exist_ok=True)
# Choose URL based on mirror setting
url = python_config['mirror_url'] if self.config['mirrors']['use_cn_mirror'] else python_config['download_url']
if self.download_file(url, cache_file, f"Python {python_config['version']}"):
return cache_file
else:
raise RuntimeError("Failed to download Python")
def download_ffmpeg(self) -> Path:
"""Download FFmpeg portable"""
ffmpeg_config = self.config['ffmpeg']
cache_file = self.cache_dir / f"ffmpeg-{ffmpeg_config['version']}-win64.zip"
if cache_file.exists():
self.log(f"Using cached FFmpeg: {cache_file}")
return cache_file
self.cache_dir.mkdir(parents=True, exist_ok=True)
url = ffmpeg_config['mirror_url'] if self.config['mirrors']['use_cn_mirror'] else ffmpeg_config['download_url']
if self.download_file(url, cache_file, f"FFmpeg {ffmpeg_config['version']}"):
return cache_file
else:
raise RuntimeError("Failed to download FFmpeg")
def extract_python(self, zip_path: Path, target_dir: Path):
"""Extract Python embedded distribution"""
self.log(f"Extracting Python to {target_dir}...")
target_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(target_dir)
# Add execute permissions to .exe files (needed on Unix systems)
if os.name != 'nt': # Not on Windows
for exe_file in target_dir.glob('*.exe'):
os.chmod(exe_file, 0o755)
for exe_file in target_dir.glob('**/*.exe'):
os.chmod(exe_file, 0o755)
self.log("Python extracted successfully", "SUCCESS")
def extract_ffmpeg(self, zip_path: Path, target_dir: Path):
"""Extract FFmpeg portable"""
self.log(f"Extracting FFmpeg to {target_dir}...")
temp_extract = target_dir.parent / "ffmpeg_temp"
temp_extract.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_extract)
# Find the bin directory (FFmpeg archive has nested structure)
bin_dir = None
for root, dirs, files in os.walk(temp_extract):
if 'bin' in dirs:
bin_dir = Path(root) / 'bin'
break
if bin_dir and bin_dir.exists():
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(bin_dir, target_dir, dirs_exist_ok=True)
shutil.rmtree(temp_extract)
self.log("FFmpeg extracted successfully", "SUCCESS")
else:
raise RuntimeError("FFmpeg bin directory not found in archive")
def prepare_python_environment(self, python_dir: Path):
"""Prepare Python environment: enable site-packages"""
self.log("Preparing Python environment...")
# Modify python311._pth to enable site-packages
pth_file = python_dir / "python311._pth"
if pth_file.exists():
with open(pth_file, 'r') as f:
lines = f.readlines()
# Uncomment "import site" line or add it
modified = False
for i, line in enumerate(lines):
if line.strip().startswith('#import site'):
lines[i] = 'import site\n'
modified = True
break
if not modified and 'import site' not in ''.join(lines):
lines.append('import site\n')
with open(pth_file, 'w') as f:
f.writelines(lines)
self.log("Enabled site-packages in Python", "SUCCESS")
# Note: On non-Windows systems, we can't run python.exe directly
# Pip and dependencies will be installed using system Python
if os.name == 'nt':
# On Windows, we can install pip directly
python_exe = python_dir / "python.exe"
get_pip_path = self.cache_dir / "get-pip.py"
if not get_pip_path.exists():
self.log("Downloading get-pip.py...")
pip_url = "https://bootstrap.pypa.io/get-pip.py"
self.download_file(pip_url, get_pip_path, "get-pip.py")
self.log("Installing pip...")
result = subprocess.run(
[str(python_exe), str(get_pip_path)],
capture_output=True,
text=True
)
if result.returncode == 0:
self.log("Pip installed successfully", "SUCCESS")
else:
self.log(f"Pip installation warning: {result.stderr}", "WARNING")
else:
self.log("Cross-platform build detected (building on non-Windows)", "INFO")
self.log("Dependencies will be installed using system Python", "INFO")
def install_dependencies(self, python_dir: Path):
"""Install project dependencies"""
self.log("Installing project dependencies...")
# Determine target directory for site-packages
site_packages = python_dir / "Lib" / "site-packages"
site_packages.mkdir(parents=True, exist_ok=True)
if os.name == 'nt':
# On Windows, use the embedded Python
python_exe = python_dir / "python.exe"
# Install uv first if configured
if self.config['build'].get('use_uv', True):
self.log("Installing uv...")
subprocess.run(
[str(python_exe), "-m", "pip", "install", "uv"],
check=True
)
# Install dependencies
if self.config['build'].get('use_uv', True):
cmd = [str(python_exe), "-m", "uv", "pip", "install", "-e", str(self.project_root)]
if self.config['mirrors']['use_cn_mirror']:
cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']])
else:
cmd = [str(python_exe), "-m", "pip", "install", "-e", str(self.project_root)]
if self.config['mirrors']['use_cn_mirror']:
cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']])
self.log(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
self.log("Dependencies installed successfully", "SUCCESS")
else:
self.log(f"Dependency installation failed:\n{result.stderr}", "ERROR")
raise RuntimeError("Failed to install dependencies")
else:
# Cross-platform build: use system Python to install to target directory
self.log("Cross-platform build: using system Python to install dependencies")
# Find a Python 3.11+ executable (not from project venv)
python_cmd = self._find_suitable_python()
if not python_cmd:
self.log("No suitable Python 3.11+ found. Please install Python 3.11+ or use Windows to build.", "ERROR")
raise RuntimeError("Python 3.11+ required for cross-platform build")
self.log(f"Using Python: {python_cmd}")
# Use pip with --target to install to specific directory
cmd = [
python_cmd, "-m", "pip", "install",
"--target", str(site_packages),
"--no-user",
"--no-warn-script-location"
]
# Read dependencies from pyproject.toml
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
self.log("tomllib/tomli not available, trying simple parsing", "WARNING")
tomllib = None
if tomllib:
pyproject_path = self.project_root / "pyproject.toml"
with open(pyproject_path, 'rb') as f:
pyproject = tomllib.load(f)
deps = pyproject.get('project', {}).get('dependencies', [])
else:
# Simple fallback: read from pyproject.toml manually
import re
pyproject_path = self.project_root / "pyproject.toml"
with open(pyproject_path, 'r') as f:
content = f.read()
# Find dependencies section
deps_match = re.search(r'dependencies\s*=\s*\[(.*?)\]', content, re.DOTALL)
if deps_match:
deps_str = deps_match.group(1)
deps = [dep.strip(' "\',\n') for dep in deps_str.split('\n') if dep.strip() and not dep.strip().startswith('#')]
else:
deps = []
if deps:
cmd.extend(deps)
if self.config['mirrors']['use_cn_mirror']:
cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']])
self.log(f"Installing {len(deps)} dependencies...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
self.log("Dependencies installed successfully", "SUCCESS")
else:
self.log(f"Dependency installation output:\n{result.stdout}", "INFO")
if result.stderr:
self.log(f"Warnings: {result.stderr}", "WARNING")
else:
self.log("No dependencies found in pyproject.toml", "WARNING")
def copy_project_files(self, target_dir: Path):
"""Copy project files to build directory"""
self.log(f"Copying project files to {target_dir}...")
exclude_patterns = self.config['build']['exclude_patterns']
def should_exclude(path: Path) -> bool:
path_str = str(path.relative_to(self.project_root))
for pattern in exclude_patterns:
if pattern.endswith('/*'):
# Directory content exclusion - must match exact directory name or start with "dirname/"
dir_name = pattern[:-2]
if path_str == dir_name or path_str.startswith(f"{dir_name}/"):
return True
elif pattern.endswith('*'):
# Wildcard pattern
if path_str.startswith(pattern[:-1]):
return True
elif '*' in pattern:
# Glob pattern (simple check)
import fnmatch
if fnmatch.fnmatch(path_str, pattern):
return True
else:
# Exact match or directory
if path_str == pattern or path_str.startswith(f"{pattern}/"):
return True
return False
target_dir.mkdir(parents=True, exist_ok=True)
# Copy files
copied_count = 0
for item in self.project_root.iterdir():
if item.name in ['.git', 'packaging', 'dist', '.venv', 'venv']:
continue
if should_exclude(item):
continue
target_path = target_dir / item.name
if item.is_file():
shutil.copy2(item, target_path)
copied_count += 1
elif item.is_dir():
shutil.copytree(item, target_path, ignore=lambda d, names: [
n for n in names if should_exclude(Path(d) / n)
])
# Count files in copied directory
copied_count += sum(1 for _ in target_path.rglob('*') if _.is_file())
self.log(f"Copied {copied_count} files", "SUCCESS")
def generate_launcher_scripts(self):
"""Generate launcher scripts from templates"""
self.log("Generating launcher scripts...")
replacements = {
'{VERSION}': self.version,
'{BUILD_DATE}': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
}
# Copy and process templates
for template_file in self.templates_dir.glob('*'):
if template_file.is_file():
target_file = self.build_dir / template_file.name
with open(template_file, 'r', encoding='utf-8') as f:
content = f.read()
# Replace placeholders
for key, value in replacements.items():
content = content.replace(key, value)
with open(target_file, 'w', encoding='utf-8', newline='\r\n') as f:
f.write(content)
self.log(f"Generated: {template_file.name}")
self.log("Launcher scripts generated", "SUCCESS")
def create_empty_directories(self):
"""Create empty directories specified in config"""
self.log("Creating empty directories...")
for dir_name in self.config['build'].get('create_empty_dirs', []):
dir_path = self.build_dir / dir_name
dir_path.mkdir(parents=True, exist_ok=True)
# Create .gitkeep to preserve directory in git
(dir_path / '.gitkeep').touch()
self.log("Empty directories created", "SUCCESS")
def create_zip_package(self):
"""Create final ZIP package"""
if not self.config['build'].get('create_zip', True):
return
zip_path = self.output_dir / f"{self.package_name}.zip"
self.log(f"Creating ZIP package: {zip_path}...")
compression_map = {
'deflate': zipfile.ZIP_DEFLATED,
'bzip2': zipfile.ZIP_BZIP2,
'lzma': zipfile.ZIP_LZMA,
}
compression = compression_map.get(
self.config['build'].get('zip_compression', 'deflate'),
zipfile.ZIP_DEFLATED
)
with zipfile.ZipFile(zip_path, 'w', compression) as zipf:
for root, dirs, files in os.walk(self.build_dir):
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(self.build_dir.parent)
zipf.write(file_path, arcname)
# Calculate file size and hash
size_mb = zip_path.stat().st_size / (1024 * 1024)
with open(zip_path, 'rb') as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
self.log(f"ZIP package created: {zip_path}", "SUCCESS")
self.log(f"Size: {size_mb:.2f} MB")
self.log(f"SHA256: {file_hash}")
# Write hash to file
hash_file = zip_path.with_suffix('.zip.sha256')
with open(hash_file, 'w') as f:
f.write(f"{file_hash} {zip_path.name}\n")
def build(self):
"""Main build process"""
self.log("=" * 60, "HEADER")
self.log(f"Building {self.package_name}", "HEADER")
self.log("=" * 60, "HEADER")
try:
# Clean build directory
if self.build_dir.exists():
self.log(f"Cleaning existing build directory: {self.build_dir}")
shutil.rmtree(self.build_dir)
self.build_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
# Download dependencies
python_zip = self.download_python()
ffmpeg_zip = self.download_ffmpeg()
# Extract Python
python_dir = self.build_dir / "python" / "python311"
self.extract_python(python_zip, python_dir)
# Extract FFmpeg
ffmpeg_dir = self.build_dir / "tools" / "ffmpeg" / "bin"
self.extract_ffmpeg(ffmpeg_zip, ffmpeg_dir)
# Prepare Python environment
self.prepare_python_environment(python_dir)
# Install dependencies
if self.config['build'].get('pre_install_deps', True):
self.install_dependencies(python_dir)
# Copy project files
project_target = self.build_dir / "Pixelle-Video"
self.copy_project_files(project_target)
# Generate launcher scripts
self.generate_launcher_scripts()
# Create empty directories
self.create_empty_directories()
# Create ZIP package
self.create_zip_package()
self.log("=" * 60, "HEADER")
self.log("Build completed successfully!", "SUCCESS")
self.log(f"Package location: {self.build_dir}", "SUCCESS")
self.log("=" * 60, "HEADER")
except Exception as e:
self.log(f"Build failed: {e}", "ERROR")
import traceback
traceback.print_exc()
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Build Windows portable package for Pixelle-Video")
parser.add_argument(
'--config',
default='packaging/windows/config/build_config.yaml',
help='Path to build configuration file'
)
parser.add_argument(
'--output',
help='Output directory (default: dist/windows)'
)
parser.add_argument(
'--cn-mirror',
action='store_true',
help='Use China mirrors for faster downloads'
)
args = parser.parse_args()
builder = WindowsPackageBuilder(
config_path=args.config,
output_dir=args.output,
use_cn_mirror=args.cn_mirror
)
builder.build()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,78 @@
# Windows Package Build Configuration
# Package information
package:
name: Pixelle-Video
version_source: pyproject.toml # Read version from pyproject.toml
architecture: win64
# Python configuration
python:
version: "3.11.9"
download_url: "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip"
# Mirror for China users (optional)
mirror_url: "https://mirrors.huaweicloud.com/python/3.11.9/python-3.11.9-embed-amd64.zip"
# FFmpeg configuration
ffmpeg:
version: "6.1.1"
download_url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
# Alternative mirror
mirror_url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
# Chrome/Chromium configuration (for html2image)
chrome:
include: false # Set to true to bundle Chrome portable
version: "120.0.6099.109"
download_url: "" # Add portable Chrome download URL if needed
note: "Users can use system Chrome or install separately"
# Build options
build:
# Files/folders to exclude from project copy
exclude_patterns:
- ".git"
- ".github"
- "__pycache__"
- "*.pyc"
- ".pytest_cache"
- ".ruff_cache"
- "*.log"
- ".DS_Store"
- "output/*" # Don't include output files
- "temp/*"
- "plans/*" # Don't include planning docs
- "repositories/*" # Don't include referenced repos
- "docs/*" # Don't include docs in package
- "test_*.py" # Don't include test files
- ".venv"
- "venv"
- "node_modules"
- "uv.lock"
# Dependencies installation
use_uv: true # Use uv for faster dependency installation
pre_install_deps: true # Install deps during build (recommended for end users)
# Output
output_dir: "dist/windows"
create_zip: true
zip_compression: "deflate" # deflate, bzip2, lzma
# Additional options
include_readme: true
include_license: true
create_empty_dirs:
- "data"
- "output"
# Download cache
cache:
enabled: true
cache_dir: "packaging/windows/.cache"
# Mirror settings (for China users)
mirrors:
use_cn_mirror: false # Set to true for faster downloads in China
pypi_mirror: "https://pypi.tuna.tsinghua.edu.cn/simple"

View File

@@ -0,0 +1,5 @@
# Requirements for building Windows package
# Install with: pip install -r requirements.txt
pyyaml>=6.0.0

View File

@@ -0,0 +1,103 @@
========================================
Pixelle-Video - Windows Portable
========================================
AI-powered video creation platform
Version: {VERSION}
Build Date: {BUILD_DATE}
========================================
Quick Start
========================================
1. Double-click "start.bat" to launch the Web UI
2. Browser will open automatically
3. Configure your API keys in the Web UI (Settings section)
That's it! Just one click to start.
You can launch multiple instances - each will use a different port automatically.
========================================
First-Time Setup
========================================
1. On first run, the Web UI will start with default configuration
2. Click on "Settings" in the Web UI to configure:
- LLM API Key (OpenAI/Qwen/DeepSeek/etc)
- LLM Base URL and Model
- ComfyUI settings (use RunningHub or local ComfyUI)
3. Click "Save Config" to save your settings
4. Configuration will be automatically saved to config.yaml
========================================
Configuration
========================================
Configuration is done through the Web UI:
1. Launch the application using start.bat
2. Click on "Settings" in the Web UI
3. Fill in the required fields:
- LLM API Key: Your LLM provider API key
- LLM Base URL: LLM API endpoint
- LLM Model: Model name (e.g., gpt-4o, qwen-max)
- ComfyUI URL: For local ComfyUI (default: http://127.0.0.1:8188)
- RunningHub API Key: For cloud image generation (optional)
4. Click "Save Config" to save
The configuration will be automatically saved to Pixelle-Video/config.yaml.
Note: You can also manually edit config.yaml if needed, but the Web UI is recommended.
========================================
Folder Structure
========================================
python/ - Python 3.11 embedded runtime
tools/ - FFmpeg and other utilities
Pixelle-Video/ - Main application
data/ - User data (BGM, templates, workflows)
output/ - Generated videos
========================================
System Requirements
========================================
- Windows 10/11 (64-bit)
- 4GB RAM minimum (8GB recommended)
- Internet connection (for API calls and ComfyUI cloud)
- Modern web browser (Chrome/Edge/Firefox)
========================================
Troubleshooting
========================================
Problem: "Python not found"
Solution: Ensure python/ folder exists and is not corrupted
Problem: "Failed to start"
Solution: Check if Python and dependencies are installed correctly
Problem: "Port already in use"
Solution: Streamlit automatically uses the next available port. You can run multiple instances simultaneously.
Problem: "Module not found"
Solution: Re-extract the package completely, don't move files
========================================
Support
========================================
GitHub: https://github.com/AIDC-AI/Pixelle-Video
Documentation: https://pixelle.ai/docs
Issues: https://github.com/AIDC-AI/Pixelle-Video/issues
========================================
License
========================================
See LICENSE file in Pixelle-Video/ folder
Copyright (c) 2025 Pixelle.AI

View File

@@ -0,0 +1,43 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ========================================
echo Pixelle-Video - Windows Launcher
echo ========================================
echo.
:: Set environment variables
set "PYTHON_HOME=%~dp0python\python311"
set "PATH=%PYTHON_HOME%;%PYTHON_HOME%\Scripts;%~dp0tools\ffmpeg\bin;%PATH%"
set "PROJECT_ROOT=%~dp0Pixelle-Video"
:: Change to project directory
cd /d "%PROJECT_ROOT%"
:: Set PYTHONPATH to project root for module imports
set "PYTHONPATH=%PROJECT_ROOT%"
:: Set PIXELLE_VIDEO_ROOT environment variable for reliable path resolution
set "PIXELLE_VIDEO_ROOT=%PROJECT_ROOT%"
:: Start Web UI
echo [Starting] Launching Pixelle-Video Web UI...
echo Browser will open automatically.
echo.
echo Note: Configure API keys and settings in the Web UI.
echo Press Ctrl+C to stop the server
echo ========================================
echo.
"%PYTHON_HOME%\python.exe" -m streamlit run web\app.py
if errorlevel 1 (
echo.
echo [ERROR] Failed to start. Please check:
echo 1. Python is properly installed
echo 2. Dependencies are installed
echo.
pause
)

View File

@@ -26,11 +26,21 @@ from typing import Optional, Tuple, Literal
def get_pixelle_video_root_path() -> str:
"""
Get Pixelle-Video root path - current working directory
Get Pixelle-Video root path
Uses PIXELLE_VIDEO_ROOT environment variable to determine project root.
This ensures reliable path resolution in both development and packaged environments.
Returns:
Current working directory as string
Project root path as string
"""
# Check environment variable (required for reliable operation)
env_root = os.environ.get("PIXELLE_VIDEO_ROOT")
if env_root and Path(env_root).exists():
return str(Path(env_root).resolve())
# Fallback to current working directory if environment variable not set
# (for development environments where env var might not be set)
return str(Path.cwd())

View File

@@ -16,10 +16,19 @@ Pixelle-Video Web UI
A simple web interface for generating short videos from content.
"""
import sys
from pathlib import Path
# Add project root to sys.path for module imports
# This ensures imports work correctly in both development and packaged environments
_script_dir = Path(__file__).resolve().parent
_project_root = _script_dir.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
import asyncio
import base64
import os
from pathlib import Path
import streamlit as st
from loguru import logger