返回 Pixelle-Video
build.py
根目录 / packaging / windows / build.py
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
692 lines PYTHON