| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Windows Package Builder for Pixelle-Video |
| 4 | |
| 5 | This script automates the creation of a Windows portable package: |
| 6 | 1. Downloads Python embedded distribution |
| 7 | 2. Downloads FFmpeg portable |
| 8 | 3. Prepares Python environment (enable site-packages, install pip) |
| 9 | 4. Installs project dependencies |
| 10 | 5. Copies project files |
| 11 | 6. Generates launcher scripts |
| 12 | 7. Creates final ZIP package |
| 13 | |
| 14 | Usage: |
| 15 | python build.py [--config CONFIG] [--output OUTPUT] [--cn-mirror] |
| 16 | """ |
| 17 | |
| 18 | import argparse |
| 19 | import hashlib |
| 20 | import os |
| 21 | import shutil |
| 22 | import subprocess |
| 23 | import sys |
| 24 | import tempfile |
| 25 | import zipfile |
| 26 | from datetime import datetime |
| 27 | from pathlib import Path |
| 28 | from typing import Optional |
| 29 | from urllib.request import urlretrieve |
| 30 | |
| 31 | try: |
| 32 | import yaml |
| 33 | except ImportError: |
| 34 | print("ERROR: PyYAML is required. Install it with: pip install pyyaml") |
| 35 | sys.exit(1) |
| 36 | |
| 37 | |
| 38 | class Color: |
| 39 | """ANSI color codes for terminal output""" |
| 40 | HEADER = '\033[95m' |
| 41 | BLUE = '\033[94m' |
| 42 | CYAN = '\033[96m' |
| 43 | GREEN = '\033[92m' |
| 44 | YELLOW = '\033[93m' |
| 45 | RED = '\033[91m' |
| 46 | RESET = '\033[0m' |
| 47 | BOLD = '\033[1m' |
| 48 | |
| 49 | |
| 50 | class WindowsPackageBuilder: |
| 51 | """Build Windows portable package for Pixelle-Video""" |
| 52 | |
| 53 | def __init__(self, config_path: str, output_dir: Optional[str] = None, use_cn_mirror: bool = False): |
| 54 | self.config_path = Path(config_path) |
| 55 | self.script_dir = Path(__file__).parent |
| 56 | self.project_root = self.script_dir.parent.parent |
| 57 | |
| 58 | # Load configuration |
| 59 | with open(self.config_path, 'r', encoding='utf-8') as f: |
| 60 | self.config = yaml.safe_load(f) |
| 61 | |
| 62 | # Override mirror setting if specified |
| 63 | if use_cn_mirror: |
| 64 | self.config['mirrors']['use_cn_mirror'] = True |
| 65 | |
| 66 | # Setup paths |
| 67 | self.output_dir = Path(output_dir) if output_dir else self.project_root / self.config['build']['output_dir'] |
| 68 | self.cache_dir = self.project_root / self.config['cache']['cache_dir'] |
| 69 | self.templates_dir = self.script_dir / 'templates' |
| 70 | |
| 71 | # Get version from pyproject.toml |
| 72 | self.version = self._read_version() |
| 73 | self.package_name = f"{self.config['package']['name']}-v{self.version}-{self.config['package']['architecture']}" |
| 74 | self.build_dir = self.output_dir / self.package_name |
| 75 | |
| 76 | def _read_version(self) -> str: |
| 77 | """Read version from pyproject.toml""" |
| 78 | pyproject_path = self.project_root / 'pyproject.toml' |
| 79 | try: |
| 80 | import tomllib |
| 81 | except ImportError: |
| 82 | # Python < 3.11 fallback |
| 83 | try: |
| 84 | import tomli as tomllib |
| 85 | except ImportError: |
| 86 | # Simple regex fallback |
| 87 | import re |
| 88 | with open(pyproject_path, 'r') as f: |
| 89 | content = f.read() |
| 90 | match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) |
| 91 | if match: |
| 92 | return match.group(1) |
| 93 | return "0.1.0" |
| 94 | |
| 95 | with open(pyproject_path, 'rb') as f: |
| 96 | pyproject = tomllib.load(f) |
| 97 | return pyproject.get('project', {}).get('version', '0.1.0') |
| 98 | |
| 99 | def log(self, message: str, level: str = "INFO"): |
| 100 | """Print colored log message""" |
| 101 | colors = { |
| 102 | "INFO": Color.BLUE, |
| 103 | "SUCCESS": Color.GREEN, |
| 104 | "WARNING": Color.YELLOW, |
| 105 | "ERROR": Color.RED, |
| 106 | "HEADER": Color.HEADER, |
| 107 | } |
| 108 | color = colors.get(level, Color.RESET) |
| 109 | print(f"{color}[{level}]{Color.RESET} {message}") |
| 110 | |
| 111 | def download_file(self, url: str, output_path: Path, description: str = "", max_retries: int = 3) -> bool: |
| 112 | """Download file with progress indication and retry support""" |
| 113 | import ssl |
| 114 | import urllib.request |
| 115 | |
| 116 | for attempt in range(max_retries): |
| 117 | try: |
| 118 | if attempt > 0: |
| 119 | self.log(f"Retry {attempt}/{max_retries}...") |
| 120 | |
| 121 | self.log(f"Downloading {description or url}...") |
| 122 | |
| 123 | # Create SSL context that's more lenient |
| 124 | ssl_context = ssl.create_default_context() |
| 125 | ssl_context.check_hostname = False |
| 126 | ssl_context.verify_mode = ssl.CERT_NONE |
| 127 | |
| 128 | def report_progress(block_num, block_size, total_size): |
| 129 | downloaded = block_num * block_size |
| 130 | percent = min(downloaded / total_size * 100, 100) if total_size > 0 else 0 |
| 131 | print(f"\r Progress: {percent:.1f}%", end='', flush=True) |
| 132 | |
| 133 | # Try with urllib first |
| 134 | opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ssl_context)) |
| 135 | urllib.request.install_opener(opener) |
| 136 | urlretrieve(url, output_path, reporthook=report_progress) |
| 137 | print() # New line after progress |
| 138 | self.log(f"Downloaded to {output_path}", "SUCCESS") |
| 139 | return True |
| 140 | |
| 141 | except Exception as e: |
| 142 | self.log(f"Download attempt {attempt + 1} failed: {e}", "WARNING") |
| 143 | if attempt < max_retries - 1: |
| 144 | import time |
| 145 | time.sleep(2) # Wait before retry |
| 146 | else: |
| 147 | self.log(f"All download attempts failed", "ERROR") |
| 148 | # Try with curl as fallback |
| 149 | return self._download_with_curl(url, output_path, description) |
| 150 | |
| 151 | return False |
| 152 | |
| 153 | def _find_suitable_python(self) -> Optional[str]: |
| 154 | """Find a suitable Python 3.11+ for installing dependencies""" |
| 155 | candidates = [ |
| 156 | # Try common locations for newer Python versions |
| 157 | '/Users/puke/miniforge3/bin/python3', # User's conda |
| 158 | '/opt/homebrew/bin/python3', # Homebrew |
| 159 | '/usr/local/bin/python3', # Manual install |
| 160 | ] |
| 161 | |
| 162 | # Also check what's in PATH |
| 163 | for i in range(11, 14): # Python 3.11, 3.12, 3.13 |
| 164 | for py_name in [f'python3.{i}', f'python{i}']: |
| 165 | found = shutil.which(py_name) |
| 166 | if found and found not in candidates: |
| 167 | candidates.append(found) |
| 168 | |
| 169 | # Check generic python3 |
| 170 | python3_path = shutil.which('python3') |
| 171 | if python3_path and '.venv' not in python3_path: |
| 172 | candidates.append(python3_path) |
| 173 | |
| 174 | # Test each candidate |
| 175 | for candidate in candidates: |
| 176 | try: |
| 177 | if not candidate: |
| 178 | continue |
| 179 | |
| 180 | # Skip if in project venv |
| 181 | if '.venv' in candidate or 'venv' in candidate: |
| 182 | continue |
| 183 | |
| 184 | # Check if path exists |
| 185 | if not os.path.exists(candidate): |
| 186 | continue |
| 187 | |
| 188 | # Check Python version |
| 189 | result = subprocess.run( |
| 190 | [candidate, '-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], |
| 191 | capture_output=True, |
| 192 | text=True, |
| 193 | timeout=5 |
| 194 | ) |
| 195 | |
| 196 | if result.returncode == 0: |
| 197 | version = result.stdout.strip() |
| 198 | major, minor = map(int, version.split('.')) |
| 199 | |
| 200 | # Need Python 3.11+ |
| 201 | if major == 3 and minor >= 11: |
| 202 | # Check if pip is available |
| 203 | pip_check = subprocess.run( |
| 204 | [candidate, '-m', 'pip', '--version'], |
| 205 | capture_output=True, |
| 206 | timeout=5 |
| 207 | ) |
| 208 | if pip_check.returncode == 0: |
| 209 | self.log(f"Found Python {version} at {candidate}", "SUCCESS") |
| 210 | return candidate |
| 211 | except Exception as e: |
| 212 | continue |
| 213 | |
| 214 | return None |
| 215 | |
| 216 | def _download_with_curl(self, url: str, output_path: Path, description: str = "") -> bool: |
| 217 | """Fallback download method using curl""" |
| 218 | try: |
| 219 | self.log(f"Trying curl fallback for {description}...") |
| 220 | result = subprocess.run( |
| 221 | ['curl', '-L', '-o', str(output_path), url, '--progress-bar'], |
| 222 | check=True, |
| 223 | capture_output=False |
| 224 | ) |
| 225 | if result.returncode == 0 and output_path.exists(): |
| 226 | self.log(f"Downloaded with curl to {output_path}", "SUCCESS") |
| 227 | return True |
| 228 | except Exception as e: |
| 229 | self.log(f"Curl download also failed: {e}", "ERROR") |
| 230 | return False |
| 231 | |
| 232 | def download_python(self) -> Path: |
| 233 | """Download Python embedded distribution""" |
| 234 | python_config = self.config['python'] |
| 235 | cache_file = self.cache_dir / f"python-{python_config['version']}-embed-amd64.zip" |
| 236 | |
| 237 | if cache_file.exists(): |
| 238 | self.log(f"Using cached Python: {cache_file}") |
| 239 | return cache_file |
| 240 | |
| 241 | self.cache_dir.mkdir(parents=True, exist_ok=True) |
| 242 | |
| 243 | # Choose URL based on mirror setting |
| 244 | url = python_config['mirror_url'] if self.config['mirrors']['use_cn_mirror'] else python_config['download_url'] |
| 245 | |
| 246 | if self.download_file(url, cache_file, f"Python {python_config['version']}"): |
| 247 | return cache_file |
| 248 | else: |
| 249 | raise RuntimeError("Failed to download Python") |
| 250 | |
| 251 | def download_ffmpeg(self) -> Path: |
| 252 | """Download FFmpeg portable""" |
| 253 | ffmpeg_config = self.config['ffmpeg'] |
| 254 | cache_file = self.cache_dir / f"ffmpeg-{ffmpeg_config['version']}-win64.zip" |
| 255 | |
| 256 | if cache_file.exists(): |
| 257 | self.log(f"Using cached FFmpeg: {cache_file}") |
| 258 | return cache_file |
| 259 | |
| 260 | self.cache_dir.mkdir(parents=True, exist_ok=True) |
| 261 | |
| 262 | url = ffmpeg_config['mirror_url'] if self.config['mirrors']['use_cn_mirror'] else ffmpeg_config['download_url'] |
| 263 | |
| 264 | if self.download_file(url, cache_file, f"FFmpeg {ffmpeg_config['version']}"): |
| 265 | return cache_file |
| 266 | else: |
| 267 | raise RuntimeError("Failed to download FFmpeg") |
| 268 | |
| 269 | def extract_python(self, zip_path: Path, target_dir: Path): |
| 270 | """Extract Python embedded distribution""" |
| 271 | self.log(f"Extracting Python to {target_dir}...") |
| 272 | target_dir.mkdir(parents=True, exist_ok=True) |
| 273 | |
| 274 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
| 275 | zip_ref.extractall(target_dir) |
| 276 | |
| 277 | # Add execute permissions to .exe files (needed on Unix systems) |
| 278 | if os.name != 'nt': # Not on Windows |
| 279 | for exe_file in target_dir.glob('*.exe'): |
| 280 | os.chmod(exe_file, 0o755) |
| 281 | for exe_file in target_dir.glob('**/*.exe'): |
| 282 | os.chmod(exe_file, 0o755) |
| 283 | |
| 284 | self.log("Python extracted successfully", "SUCCESS") |
| 285 | |
| 286 | def extract_ffmpeg(self, zip_path: Path, target_dir: Path): |
| 287 | """Extract FFmpeg portable""" |
| 288 | self.log(f"Extracting FFmpeg to {target_dir}...") |
| 289 | temp_extract = target_dir.parent / "ffmpeg_temp" |
| 290 | temp_extract.mkdir(parents=True, exist_ok=True) |
| 291 | |
| 292 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
| 293 | zip_ref.extractall(temp_extract) |
| 294 | |
| 295 | # Find the bin directory (FFmpeg archive has nested structure) |
| 296 | bin_dir = None |
| 297 | for root, dirs, files in os.walk(temp_extract): |
| 298 | if 'bin' in dirs: |
| 299 | bin_dir = Path(root) / 'bin' |
| 300 | break |
| 301 | |
| 302 | if bin_dir and bin_dir.exists(): |
| 303 | target_dir.mkdir(parents=True, exist_ok=True) |
| 304 | shutil.copytree(bin_dir, target_dir, dirs_exist_ok=True) |
| 305 | shutil.rmtree(temp_extract) |
| 306 | self.log("FFmpeg extracted successfully", "SUCCESS") |
| 307 | else: |
| 308 | raise RuntimeError("FFmpeg bin directory not found in archive") |
| 309 | |
| 310 | def prepare_python_environment(self, python_dir: Path): |
| 311 | """Prepare Python environment: enable site-packages""" |
| 312 | self.log("Preparing Python environment...") |
| 313 | |
| 314 | # Modify python311._pth to enable site-packages |
| 315 | pth_file = python_dir / "python311._pth" |
| 316 | if pth_file.exists(): |
| 317 | with open(pth_file, 'r') as f: |
| 318 | lines = f.readlines() |
| 319 | |
| 320 | # Uncomment "import site" line or add it |
| 321 | modified = False |
| 322 | for i, line in enumerate(lines): |
| 323 | if line.strip().startswith('#import site'): |
| 324 | lines[i] = 'import site\n' |
| 325 | modified = True |
| 326 | break |
| 327 | |
| 328 | if not modified and 'import site' not in ''.join(lines): |
| 329 | lines.append('import site\n') |
| 330 | |
| 331 | with open(pth_file, 'w') as f: |
| 332 | f.writelines(lines) |
| 333 | |
| 334 | self.log("Enabled site-packages in Python", "SUCCESS") |
| 335 | |
| 336 | # Note: On non-Windows systems, we can't run python.exe directly |
| 337 | # Pip and dependencies will be installed using system Python |
| 338 | if os.name == 'nt': |
| 339 | # On Windows, we can install pip directly |
| 340 | python_exe = python_dir / "python.exe" |
| 341 | get_pip_path = self.cache_dir / "get-pip.py" |
| 342 | |
| 343 | if not get_pip_path.exists(): |
| 344 | self.log("Downloading get-pip.py...") |
| 345 | pip_url = "https://bootstrap.pypa.io/get-pip.py" |
| 346 | self.download_file(pip_url, get_pip_path, "get-pip.py") |
| 347 | |
| 348 | self.log("Installing pip...") |
| 349 | result = subprocess.run( |
| 350 | [str(python_exe), str(get_pip_path)], |
| 351 | capture_output=True, |
| 352 | text=True |
| 353 | ) |
| 354 | |
| 355 | if result.returncode == 0: |
| 356 | self.log("Pip installed successfully", "SUCCESS") |
| 357 | else: |
| 358 | self.log(f"Pip installation warning: {result.stderr}", "WARNING") |
| 359 | else: |
| 360 | self.log("Cross-platform build detected (building on non-Windows)", "INFO") |
| 361 | self.log("Dependencies will be installed using system Python", "INFO") |
| 362 | |
| 363 | def install_dependencies(self, python_dir: Path): |
| 364 | """Install project dependencies""" |
| 365 | self.log("Installing project dependencies...") |
| 366 | |
| 367 | # Determine target directory for site-packages |
| 368 | site_packages = python_dir / "Lib" / "site-packages" |
| 369 | site_packages.mkdir(parents=True, exist_ok=True) |
| 370 | |
| 371 | if os.name == 'nt': |
| 372 | # On Windows, use the embedded Python |
| 373 | python_exe = python_dir / "python.exe" |
| 374 | |
| 375 | # Install uv first if configured |
| 376 | if self.config['build'].get('use_uv', True): |
| 377 | self.log("Installing uv...") |
| 378 | subprocess.run( |
| 379 | [str(python_exe), "-m", "pip", "install", "uv"], |
| 380 | check=True |
| 381 | ) |
| 382 | |
| 383 | # Install dependencies |
| 384 | if self.config['build'].get('use_uv', True): |
| 385 | cmd = [str(python_exe), "-m", "uv", "pip", "install", "-e", str(self.project_root)] |
| 386 | if self.config['mirrors']['use_cn_mirror']: |
| 387 | cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']]) |
| 388 | else: |
| 389 | cmd = [str(python_exe), "-m", "pip", "install", "-e", str(self.project_root)] |
| 390 | if self.config['mirrors']['use_cn_mirror']: |
| 391 | cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']]) |
| 392 | |
| 393 | self.log(f"Running: {' '.join(cmd)}") |
| 394 | result = subprocess.run(cmd, capture_output=True, text=True) |
| 395 | |
| 396 | if result.returncode == 0: |
| 397 | self.log("Dependencies installed successfully", "SUCCESS") |
| 398 | else: |
| 399 | self.log(f"Dependency installation failed:\n{result.stderr}", "ERROR") |
| 400 | raise RuntimeError("Failed to install dependencies") |
| 401 | else: |
| 402 | # Cross-platform build: use system Python to install to target directory |
| 403 | self.log("Cross-platform build: using system Python to install dependencies") |
| 404 | |
| 405 | # Find a Python 3.11+ executable (not from project venv) |
| 406 | python_cmd = self._find_suitable_python() |
| 407 | |
| 408 | if not python_cmd: |
| 409 | self.log("No suitable Python 3.11+ found. Please install Python 3.11+ or use Windows to build.", "ERROR") |
| 410 | raise RuntimeError("Python 3.11+ required for cross-platform build") |
| 411 | |
| 412 | self.log(f"Using Python: {python_cmd}") |
| 413 | |
| 414 | # Use pip with --target to install to specific directory |
| 415 | cmd = [ |
| 416 | python_cmd, "-m", "pip", "install", |
| 417 | "--target", str(site_packages), |
| 418 | "--no-user", |
| 419 | "--no-warn-script-location" |
| 420 | ] |
| 421 | |
| 422 | # Read dependencies from pyproject.toml |
| 423 | try: |
| 424 | import tomllib |
| 425 | except ImportError: |
| 426 | try: |
| 427 | import tomli as tomllib |
| 428 | except ImportError: |
| 429 | self.log("tomllib/tomli not available, trying simple parsing", "WARNING") |
| 430 | tomllib = None |
| 431 | |
| 432 | if tomllib: |
| 433 | pyproject_path = self.project_root / "pyproject.toml" |
| 434 | with open(pyproject_path, 'rb') as f: |
| 435 | pyproject = tomllib.load(f) |
| 436 | deps = pyproject.get('project', {}).get('dependencies', []) |
| 437 | else: |
| 438 | # Simple fallback: read from pyproject.toml manually |
| 439 | import re |
| 440 | pyproject_path = self.project_root / "pyproject.toml" |
| 441 | with open(pyproject_path, 'r') as f: |
| 442 | content = f.read() |
| 443 | # Find dependencies section |
| 444 | deps_match = re.search(r'dependencies\s*=\s*\[(.*?)\]', content, re.DOTALL) |
| 445 | if deps_match: |
| 446 | deps_str = deps_match.group(1) |
| 447 | deps = [dep.strip(' "\',\n') for dep in deps_str.split('\n') if dep.strip() and not dep.strip().startswith('#')] |
| 448 | else: |
| 449 | deps = [] |
| 450 | |
| 451 | if deps: |
| 452 | cmd.extend(deps) |
| 453 | |
| 454 | if self.config['mirrors']['use_cn_mirror']: |
| 455 | cmd.extend(["--index-url", self.config['mirrors']['pypi_mirror']]) |
| 456 | |
| 457 | self.log(f"Installing {len(deps)} dependencies...") |
| 458 | result = subprocess.run(cmd, capture_output=True, text=True) |
| 459 | |
| 460 | if result.returncode == 0: |
| 461 | self.log("Dependencies installed successfully", "SUCCESS") |
| 462 | else: |
| 463 | self.log(f"Dependency installation output:\n{result.stdout}", "INFO") |
| 464 | if result.stderr: |
| 465 | self.log(f"Warnings: {result.stderr}", "WARNING") |
| 466 | else: |
| 467 | self.log("No dependencies found in pyproject.toml", "WARNING") |
| 468 | |
| 469 | def copy_project_files(self, target_dir: Path): |
| 470 | """Copy project files to build directory""" |
| 471 | self.log(f"Copying project files to {target_dir}...") |
| 472 | |
| 473 | exclude_patterns = self.config['build']['exclude_patterns'] |
| 474 | |
| 475 | def should_exclude(path: Path) -> bool: |
| 476 | path_str = str(path.relative_to(self.project_root)) |
| 477 | for pattern in exclude_patterns: |
| 478 | if pattern.endswith('/*'): |
| 479 | # Directory content exclusion - must match exact directory name or start with "dirname/" |
| 480 | dir_name = pattern[:-2] |
| 481 | if path_str == dir_name or path_str.startswith(f"{dir_name}/"): |
| 482 | return True |
| 483 | elif pattern.endswith('*'): |
| 484 | # Wildcard pattern |
| 485 | if path_str.startswith(pattern[:-1]): |
| 486 | return True |
| 487 | elif '*' in pattern: |
| 488 | # Glob pattern (simple check) |
| 489 | import fnmatch |
| 490 | if fnmatch.fnmatch(path_str, pattern): |
| 491 | return True |
| 492 | else: |
| 493 | # Exact match or directory |
| 494 | if path_str == pattern or path_str.startswith(f"{pattern}/"): |
| 495 | return True |
| 496 | return False |
| 497 | |
| 498 | target_dir.mkdir(parents=True, exist_ok=True) |
| 499 | |
| 500 | # Copy files |
| 501 | copied_count = 0 |
| 502 | for item in self.project_root.iterdir(): |
| 503 | if item.name in ['.git', 'packaging', 'dist', '.venv', 'venv']: |
| 504 | continue |
| 505 | |
| 506 | if should_exclude(item): |
| 507 | continue |
| 508 | |
| 509 | target_path = target_dir / item.name |
| 510 | |
| 511 | if item.is_file(): |
| 512 | shutil.copy2(item, target_path) |
| 513 | copied_count += 1 |
| 514 | elif item.is_dir(): |
| 515 | shutil.copytree(item, target_path, ignore=lambda d, names: [ |
| 516 | n for n in names if should_exclude(Path(d) / n) |
| 517 | ]) |
| 518 | # Count files in copied directory |
| 519 | copied_count += sum(1 for _ in target_path.rglob('*') if _.is_file()) |
| 520 | |
| 521 | self.log(f"Copied {copied_count} files", "SUCCESS") |
| 522 | |
| 523 | def generate_launcher_scripts(self): |
| 524 | """Generate launcher scripts from templates""" |
| 525 | self.log("Generating launcher scripts...") |
| 526 | |
| 527 | replacements = { |
| 528 | '{VERSION}': self.version, |
| 529 | '{BUILD_DATE}': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), |
| 530 | } |
| 531 | |
| 532 | # Copy and process templates |
| 533 | for template_file in self.templates_dir.glob('*'): |
| 534 | if template_file.is_file(): |
| 535 | target_file = self.build_dir / template_file.name |
| 536 | |
| 537 | with open(template_file, 'r', encoding='utf-8') as f: |
| 538 | content = f.read() |
| 539 | |
| 540 | # Replace placeholders |
| 541 | for key, value in replacements.items(): |
| 542 | content = content.replace(key, value) |
| 543 | |
| 544 | with open(target_file, 'w', encoding='utf-8', newline='\r\n') as f: |
| 545 | f.write(content) |
| 546 | |
| 547 | self.log(f"Generated: {template_file.name}") |
| 548 | |
| 549 | self.log("Launcher scripts generated", "SUCCESS") |
| 550 | |
| 551 | def create_empty_directories(self): |
| 552 | """Create empty directories specified in config""" |
| 553 | self.log("Creating empty directories...") |
| 554 | |
| 555 | for dir_name in self.config['build'].get('create_empty_dirs', []): |
| 556 | dir_path = self.build_dir / dir_name |
| 557 | dir_path.mkdir(parents=True, exist_ok=True) |
| 558 | # Create .gitkeep to preserve directory in git |
| 559 | (dir_path / '.gitkeep').touch() |
| 560 | |
| 561 | self.log("Empty directories created", "SUCCESS") |
| 562 | |
| 563 | def create_zip_package(self): |
| 564 | """Create final ZIP package""" |
| 565 | if not self.config['build'].get('create_zip', True): |
| 566 | return |
| 567 | |
| 568 | zip_path = self.output_dir / f"{self.package_name}.zip" |
| 569 | self.log(f"Creating ZIP package: {zip_path}...") |
| 570 | |
| 571 | compression_map = { |
| 572 | 'deflate': zipfile.ZIP_DEFLATED, |
| 573 | 'bzip2': zipfile.ZIP_BZIP2, |
| 574 | 'lzma': zipfile.ZIP_LZMA, |
| 575 | } |
| 576 | compression = compression_map.get( |
| 577 | self.config['build'].get('zip_compression', 'deflate'), |
| 578 | zipfile.ZIP_DEFLATED |
| 579 | ) |
| 580 | |
| 581 | with zipfile.ZipFile(zip_path, 'w', compression) as zipf: |
| 582 | for root, dirs, files in os.walk(self.build_dir): |
| 583 | for file in files: |
| 584 | file_path = Path(root) / file |
| 585 | arcname = file_path.relative_to(self.build_dir.parent) |
| 586 | zipf.write(file_path, arcname) |
| 587 | |
| 588 | # Calculate file size and hash |
| 589 | size_mb = zip_path.stat().st_size / (1024 * 1024) |
| 590 | |
| 591 | with open(zip_path, 'rb') as f: |
| 592 | file_hash = hashlib.sha256(f.read()).hexdigest() |
| 593 | |
| 594 | self.log(f"ZIP package created: {zip_path}", "SUCCESS") |
| 595 | self.log(f"Size: {size_mb:.2f} MB") |
| 596 | self.log(f"SHA256: {file_hash}") |
| 597 | |
| 598 | # Write hash to file |
| 599 | hash_file = zip_path.with_suffix('.zip.sha256') |
| 600 | with open(hash_file, 'w') as f: |
| 601 | f.write(f"{file_hash} {zip_path.name}\n") |
| 602 | |
| 603 | def build(self): |
| 604 | """Main build process""" |
| 605 | self.log("=" * 60, "HEADER") |
| 606 | self.log(f"Building {self.package_name}", "HEADER") |
| 607 | self.log("=" * 60, "HEADER") |
| 608 | |
| 609 | try: |
| 610 | # Clean build directory |
| 611 | if self.build_dir.exists(): |
| 612 | self.log(f"Cleaning existing build directory: {self.build_dir}") |
| 613 | shutil.rmtree(self.build_dir) |
| 614 | |
| 615 | self.build_dir.mkdir(parents=True, exist_ok=True) |
| 616 | self.output_dir.mkdir(parents=True, exist_ok=True) |
| 617 | |
| 618 | # Download dependencies |
| 619 | python_zip = self.download_python() |
| 620 | ffmpeg_zip = self.download_ffmpeg() |
| 621 | |
| 622 | # Extract Python |
| 623 | python_dir = self.build_dir / "python" / "python311" |
| 624 | self.extract_python(python_zip, python_dir) |
| 625 | |
| 626 | # Extract FFmpeg |
| 627 | ffmpeg_dir = self.build_dir / "tools" / "ffmpeg" / "bin" |
| 628 | self.extract_ffmpeg(ffmpeg_zip, ffmpeg_dir) |
| 629 | |
| 630 | # Prepare Python environment |
| 631 | self.prepare_python_environment(python_dir) |
| 632 | |
| 633 | # Install dependencies |
| 634 | if self.config['build'].get('pre_install_deps', True): |
| 635 | self.install_dependencies(python_dir) |
| 636 | |
| 637 | # Copy project files |
| 638 | project_target = self.build_dir / "Pixelle-Video" |
| 639 | self.copy_project_files(project_target) |
| 640 | |
| 641 | # Generate launcher scripts |
| 642 | self.generate_launcher_scripts() |
| 643 | |
| 644 | # Create empty directories |
| 645 | self.create_empty_directories() |
| 646 | |
| 647 | # Create ZIP package |
| 648 | self.create_zip_package() |
| 649 | |
| 650 | self.log("=" * 60, "HEADER") |
| 651 | self.log("Build completed successfully!", "SUCCESS") |
| 652 | self.log(f"Package location: {self.build_dir}", "SUCCESS") |
| 653 | self.log("=" * 60, "HEADER") |
| 654 | |
| 655 | except Exception as e: |
| 656 | self.log(f"Build failed: {e}", "ERROR") |
| 657 | import traceback |
| 658 | traceback.print_exc() |
| 659 | sys.exit(1) |
| 660 | |
| 661 | |
| 662 | def main(): |
| 663 | parser = argparse.ArgumentParser(description="Build Windows portable package for Pixelle-Video") |
| 664 | parser.add_argument( |
| 665 | '--config', |
| 666 | default='packaging/windows/config/build_config.yaml', |
| 667 | help='Path to build configuration file' |
| 668 | ) |
| 669 | parser.add_argument( |
| 670 | '--output', |
| 671 | help='Output directory (default: dist/windows)' |
| 672 | ) |
| 673 | parser.add_argument( |
| 674 | '--cn-mirror', |
| 675 | action='store_true', |
| 676 | help='Use China mirrors for faster downloads' |
| 677 | ) |
| 678 | |
| 679 | args = parser.parse_args() |
| 680 | |
| 681 | builder = WindowsPackageBuilder( |
| 682 | config_path=args.config, |
| 683 | output_dir=args.output, |
| 684 | use_cn_mirror=args.cn_mirror |
| 685 | ) |
| 686 | builder.build() |
| 687 | |
| 688 | |
| 689 | if __name__ == '__main__': |
| 690 | main() |
| 691 | |
| 692 |