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