""" HTML-based Frame Generator Service Renders HTML templates to frame images with variable substitution Linux Environment Requirements: - fontconfig package must be installed - Basic fonts (e.g., fonts-liberation, fonts-noto) recommended Ubuntu/Debian: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk CentOS/RHEL: sudo yum install -y fontconfig liberation-fonts google-noto-cjk-fonts """ import os import re import uuid from typing import Dict, Any, Optional from pathlib import Path from html2image import Html2Image from loguru import logger from pixelle_video.utils.template_util import parse_template_size class HTMLFrameGenerator: """ HTML-based frame generator Renders HTML templates to frame images with variable substitution. Users can create custom templates using any HTML/CSS. Usage: >>> generator = HTMLFrameGenerator("templates/modern.html") >>> frame_path = await generator.generate_frame( ... topic="Why reading matters", ... text="Reading builds new neural pathways...", ... image="/path/to/image.png", ... ext={"content_title": "Sample Title", "content_author": "Author Name"} ... ) """ def __init__(self, template_path: str): """ Initialize HTML frame generator Args: template_path: Path to HTML template file (e.g., "templates/1080x1920/default.html") """ self.template_path = template_path self.template = self._load_template(template_path) # Parse video size from template path self.width, self.height = parse_template_size(template_path) self.hti = None # Lazy init to avoid overhead self._check_linux_dependencies() logger.debug(f"Loaded HTML template: {template_path} (size: {self.width}x{self.height})") def _check_linux_dependencies(self): """Check Linux system dependencies and warn if missing""" if os.name != 'posix': return try: import subprocess # Check fontconfig result = subprocess.run( ['fc-list'], capture_output=True, timeout=2 ) if result.returncode != 0: logger.warning( "⚠️ fontconfig not found or not working properly. " "Install with: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk" ) elif not result.stdout: logger.warning( "⚠️ No fonts detected by fontconfig. " "Install fonts with: sudo apt-get install -y fonts-liberation fonts-noto-cjk" ) else: logger.debug(f"✓ Fontconfig detected {len(result.stdout.splitlines())} fonts") except FileNotFoundError: logger.warning( "⚠️ fontconfig (fc-list) not found on system. " "Install with: sudo apt-get install -y fontconfig" ) except Exception as e: logger.debug(f"Could not check fontconfig status: {e}") def _load_template(self, template_path: str) -> str: """Load HTML template from file""" path = Path(template_path) if not path.exists(): raise FileNotFoundError(f"Template not found: {template_path}") with open(path, 'r', encoding='utf-8') as f: content = f.read() logger.debug(f"Template loaded: {len(content)} chars") return content def parse_template_parameters(self) -> Dict[str, Dict[str, Any]]: """ Parse custom parameters from HTML template Supports syntax: {{param:type=default}} - {{param}} -> text type, no default - {{param=value}} -> text type, with default - {{param:type}} -> specified type, no default - {{param:type=value}} -> specified type, with default Supported types: text, number, color, bool Returns: Dictionary of custom parameters with their configurations: { 'param_name': { 'type': 'text' | 'number' | 'color' | 'bool', 'default': Any, 'label': str # same as param_name } } """ # Preset parameters that should be ignored PRESET_PARAMS = {'title', 'text', 'image', 'content_title', 'content_author', 'content_subtitle', 'content_genre'} # Pattern: {{param_name:type=default}} or {{param_name=default}} or {{param_name:type}} or {{param_name}} # Param name: must start with letter or underscore, can contain letters, digits, underscores PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}' params = {} for match in re.finditer(PARAM_PATTERN, self.template): param_name = match.group(1) param_type = match.group(2) or 'text' # Default to text default_value = match.group(3) # Skip preset parameters if param_name in PRESET_PARAMS: continue # Skip if already parsed (use first occurrence) if param_name in params: continue # Validate type if param_type not in {'text', 'number', 'color', 'bool'}: logger.warning(f"Unknown parameter type '{param_type}' for '{param_name}', defaulting to 'text'") param_type = 'text' # Parse default value based on type parsed_default = self._parse_default_value(param_type, default_value) params[param_name] = { 'type': param_type, 'default': parsed_default, 'label': param_name, # Use param name as label } if params: logger.debug(f"Parsed {len(params)} custom parameter(s) from template: {list(params.keys())}") return params def _parse_default_value(self, param_type: str, value_str: Optional[str]) -> Any: """ Parse default value based on parameter type Args: param_type: Type of parameter (text, number, color, bool) value_str: String value to parse (can be None) Returns: Parsed value with appropriate type """ if value_str is None: # No default value specified, return type-specific defaults return { 'text': '', 'number': 0, 'color': '#000000', 'bool': False, }.get(param_type, '') if param_type == 'number': try: # Try int first, then float if '.' in value_str: return float(value_str) else: return int(value_str) except ValueError: logger.warning(f"Invalid number value '{value_str}', using 0") return 0 elif param_type == 'bool': # Accept: true/false, 1/0, yes/no, on/off (case-insensitive) return value_str.lower() in {'true', '1', 'yes', 'on'} elif param_type == 'color': # Auto-add # if missing if value_str.startswith('#'): return value_str else: return f'#{value_str}' else: # text return value_str def _replace_parameters(self, html: str, values: Dict[str, Any]) -> str: """ Replace parameter placeholders with actual values Supports DSL syntax: {{param:type=default}} - If value provided in values dict, use it - Otherwise, use default value from placeholder - If no default, use empty string Args: html: HTML template content values: Dictionary of parameter values Returns: HTML with placeholders replaced """ PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}' def replacer(match): param_name = match.group(1) param_type = match.group(2) or 'text' default_value_str = match.group(3) # Check if value is provided if param_name in values: value = values[param_name] # Convert bool to string for HTML if isinstance(value, bool): return 'true' if value else 'false' return str(value) if value is not None else '' # Use default value from placeholder if available elif default_value_str: return default_value_str # No value and no default else: return '' return re.sub(PARAM_PATTERN, replacer, html) def _find_chrome_executable(self) -> Optional[str]: """ Find suitable Chrome/Chromium executable, preferring non-snap versions Returns: Path to Chrome executable or None to use default """ if os.name != 'posix': return None import subprocess # Preferred browsers (non-snap versions) candidates = [ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/local/bin/chrome', '/usr/local/bin/chromium', ] # Check each candidate for path in candidates: if os.path.exists(path) and os.access(path, os.X_OK): try: # Verify it's not a snap by checking the path result = subprocess.run( ['readlink', '-f', path], capture_output=True, text=True, timeout=1 ) real_path = result.stdout.strip() if '/snap/' not in real_path: logger.info(f"✓ Found non-snap browser: {path} -> {real_path}") return path else: logger.debug(f"✗ Skipping snap browser: {path}") except Exception as e: logger.debug(f"Error checking {path}: {e}") # Warn if no suitable browser found logger.warning( "⚠️ No non-snap Chrome/Chromium found. Snap browsers have AppArmor restrictions.\n" " Install system Chrome with:\n" " wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb\n" " sudo dpkg -i google-chrome-stable_current_amd64.deb\n" " Or install Chromium: sudo apt-get install -y chromium-browser" ) return None def _ensure_hti(self, width: int, height: int): """Lazily initialize Html2Image instance""" if self.hti is None: # Configure Chrome flags for Linux headless environment custom_flags = [ '--no-sandbox', # Bypass AppArmor/sandbox restrictions '--disable-dev-shm-usage', # Avoid shared memory issues '--disable-gpu', # Disable GPU acceleration '--disable-software-rasterizer', # Disable software rasterizer '--disable-extensions', # Disable extensions '--disable-setuid-sandbox', # Additional sandbox bypass '--disable-dbus', # Disable DBus to avoid permission errors '--hide-scrollbars', # Hide scrollbars for cleaner output '--mute-audio', # Mute audio '--disable-background-networking', # Disable background networking '--disable-features=TranslateUI', # Disable translate UI '--disable-ipc-flooding-protection', # Improve performance '--no-first-run', # Skip first run dialogs '--no-default-browser-check', # Skip default browser check '--disable-backgrounding-occluded-windows', # Improve performance '--disable-renderer-backgrounding', # Improve performance ] # Try to find non-snap browser browser_path = self._find_chrome_executable() kwargs = { 'size': (width, height), 'custom_flags': custom_flags } if browser_path: kwargs['browser_executable'] = browser_path self.hti = Html2Image(**kwargs) if browser_path: logger.debug(f"Initialized Html2Image with size ({width}, {height}), {len(custom_flags)} custom flags, using browser: {browser_path}") else: logger.debug(f"Initialized Html2Image with size ({width}, {height}) and {len(custom_flags)} custom flags") async def generate_frame( self, title: str, text: str, image: str, ext: Optional[Dict[str, Any]] = None, output_path: Optional[str] = None ) -> str: """ Generate frame from HTML template Video size is automatically determined from template path during initialization. Args: title: Video title text: Narration text for this frame image: Path to AI-generated image (supports relative path, absolute path, or HTTP URL) ext: Additional data (content_title, content_author, etc.) output_path: Custom output path (auto-generated if None) Returns: Path to generated frame image """ # Convert image path to absolute path or file:// URL for html2image if image and not image.startswith(('http://', 'https://', 'data:', 'file://')): # Local file path - convert to absolute path and file:// URL image_path = Path(image) if not image_path.is_absolute(): # Relative to current working directory (project root) image_path = Path.cwd() / image # Ensure the file exists if not image_path.exists(): logger.warning(f"Image file not found: {image_path}") else: # Convert to file:// URL for html2image compatibility image = image_path.as_uri() logger.debug(f"Converted image path to: {image}") # Build variable context context = { # Required variables "title": title, "text": text, "image": image, } # Add all ext fields if ext: context.update(ext) # Replace variables in HTML (supports DSL syntax: {{param:type=default}}) html = self._replace_parameters(self.template, context) # Use provided output path or auto-generate if output_path is None: # Fallback: auto-generate (for backward compatibility) from pixelle_video.utils.os_util import get_output_path output_filename = f"frame_{uuid.uuid4().hex[:16]}.png" output_path = get_output_path(output_filename) else: # Ensure parent directory exists import os os.makedirs(os.path.dirname(output_path), exist_ok=True) # Extract filename from output_path for html2image import os output_filename = os.path.basename(output_path) output_dir = os.path.dirname(output_path) # Ensure Html2Image is initialized with template's size self._ensure_hti(self.width, self.height) # Render HTML to image logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})") try: self.hti.screenshot( html_str=html, save_as=output_filename ) # html2image saves to current directory by default, move to target directory import shutil temp_file = os.path.join(os.getcwd(), output_filename) if os.path.exists(temp_file) and temp_file != output_path: shutil.move(temp_file, output_path) logger.info(f"✅ Frame generated: {output_path}") return output_path except Exception as e: logger.error(f"Failed to render HTML template: {e}") raise RuntimeError(f"HTML rendering failed: {e}")