# Copyright (C) 2025 AIDC-AI # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ 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 """ from math import log 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 PIL import Image 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"} ... ) """ # Workaround for Chromium screenshot height issue # Due to a Chromium bug that causes screenshots to be cropped at the bottom, # we temporarily render with extra height and then crop it back. # See: https://issues.chromium.org/issues/405165895 # This is a temporary workaround until the issue is fixed in Chromium. CHROMIUM_HEIGHT_OFFSET = 87 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_media_size_from_meta(self) -> tuple[Optional[int], Optional[int]]: """ Parse media size from meta tags in template Looks for meta tags: - - Returns: Tuple of (width, height) or (None, None) if not found """ from bs4 import BeautifulSoup try: soup = BeautifulSoup(self.template, 'html.parser') # Find width and height meta tags width_meta = soup.find('meta', attrs={'name': 'template:media-width'}) height_meta = soup.find('meta', attrs={'name': 'template:media-height'}) if width_meta and height_meta: width = int(width_meta.get('content', 0)) height = int(height_meta.get('content', 0)) if width > 0 and height > 0: logger.debug(f"Found media size in meta tags: {width}x{height}") return width, height return None, None except Exception as e: logger.warning(f"Failed to parse media size from meta tags: {e}") return None, None def get_media_size(self) -> tuple[int, int]: """ Get media size for image/video generation Returns media size specified in template meta tags. Returns: Tuple of (width, height) """ media_width, media_height = self._parse_media_size_from_meta() if media_width and media_height: return media_width, media_height # Fallback to default if not specified (should not happen with properly configured templates) logger.warning(f"No media size meta tags found in template {self.template_path}, using fallback 1024x1024") return 1024, 1024 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 (auto-injected by system) PRESET_PARAMS = {'title', 'text', 'image', 'index'} # 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 = [ '--default-background-color=00000000', '--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() # Workaround: Add extra height to compensate for Chromium screenshot cropping bug # The extra pixels will be cropped back in generate_frame() after rendering # See CHROMIUM_HEIGHT_OFFSET constant for details kwargs = { 'size': (width, height + self.CHROMIUM_HEIGHT_OFFSET), '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) # Workaround: Crop image to remove extra height added to compensate for Chromium bug # Chromium screenshots are cropped at the bottom, so we render with extra height # and then crop it back to the desired size. See CHROMIUM_HEIGHT_OFFSET constant. # Reference: https://issues.chromium.org/issues/405165895 if os.path.exists(output_path): with Image.open(output_path) as img: # Crop from (0, 0) to (originWidth, originHeight) # This removes the extra CHROMIUM_HEIGHT_OFFSET pixels added during rendering cropped_img = img.crop((0, 0, self.width, self.height)) cropped_img.save(output_path) logger.debug(f"Cropped image to size: {self.width}x{self.height} (removed {self.CHROMIUM_HEIGHT_OFFSET}px workaround offset)") 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}")