Files
AI-Video/packaging/windows/build.py
2025-11-09 21:55:10 +08:00

692 lines
27 KiB
Python

#!/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()