| 1 | # Copyright (C) 2025 AIDC-AI |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 7 | # Unless required by applicable law or agreed to in writing, software |
| 8 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 10 | # See the License for the specific language governing permissions and |
| 11 | # limitations under the License. |
| 12 | |
| 13 | """ |
| 14 | HTML-based Frame Generator Service |
| 15 | |
| 16 | Renders HTML templates to frame images using Playwright for headless browser rendering. |
| 17 | |
| 18 | Linux Environment Requirements: |
| 19 | - fontconfig package must be installed |
| 20 | - Basic fonts (e.g., fonts-liberation, fonts-noto) recommended |
| 21 | |
| 22 | Ubuntu/Debian: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk |
| 23 | CentOS/RHEL: sudo yum install -y fontconfig liberation-fonts google-noto-cjk-fonts |
| 24 | |
| 25 | Playwright browser install: playwright install --with-deps chromium |
| 26 | """ |
| 27 | |
| 28 | import asyncio |
| 29 | import os |
| 30 | import re |
| 31 | import tempfile |
| 32 | import uuid |
| 33 | import asyncio |
| 34 | from typing import Dict, Any, Optional |
| 35 | from pathlib import Path |
| 36 | from loguru import logger |
| 37 | |
| 38 | from pixelle_video.utils.template_util import parse_template_size |
| 39 | |
| 40 | |
| 41 | class HTMLFrameGenerator: |
| 42 | """ |
| 43 | HTML-based frame generator |
| 44 | |
| 45 | Renders HTML templates to frame images with variable substitution. |
| 46 | Uses Playwright for reliable headless browser rendering. |
| 47 | |
| 48 | Usage: |
| 49 | >>> generator = HTMLFrameGenerator("templates/modern.html") |
| 50 | >>> frame_path = await generator.generate_frame( |
| 51 | ... topic="Why reading matters", |
| 52 | ... text="Reading builds new neural pathways...", |
| 53 | ... image="/path/to/image.png", |
| 54 | ... ext={"content_title": "Sample Title", "content_author": "Author Name"} |
| 55 | ... ) |
| 56 | """ |
| 57 | |
| 58 | _browser = None |
| 59 | _playwright = None |
| 60 | _browser_loop = None |
| 61 | |
| 62 | def __init__(self, template_path: str): |
| 63 | """ |
| 64 | Initialize HTML frame generator |
| 65 | |
| 66 | Args: |
| 67 | template_path: Path to HTML template file (e.g., "templates/1080x1920/default.html") |
| 68 | """ |
| 69 | self.template_path = template_path |
| 70 | self.template = self._load_template(template_path) |
| 71 | |
| 72 | # Parse video size from template path |
| 73 | self.width, self.height = parse_template_size(template_path) |
| 74 | |
| 75 | self._check_linux_dependencies() |
| 76 | logger.debug(f"Loaded HTML template: {template_path} (size: {self.width}x{self.height})") |
| 77 | |
| 78 | |
| 79 | def _check_linux_dependencies(self): |
| 80 | """Check Linux system dependencies and warn if missing""" |
| 81 | if os.name != 'posix': |
| 82 | return |
| 83 | |
| 84 | try: |
| 85 | import subprocess |
| 86 | |
| 87 | result = subprocess.run( |
| 88 | ['fc-list'], |
| 89 | capture_output=True, |
| 90 | timeout=2 |
| 91 | ) |
| 92 | |
| 93 | if result.returncode != 0: |
| 94 | logger.warning( |
| 95 | "fontconfig not found or not working properly. " |
| 96 | "Install with: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk" |
| 97 | ) |
| 98 | elif not result.stdout: |
| 99 | logger.warning( |
| 100 | "No fonts detected by fontconfig. " |
| 101 | "Install fonts with: sudo apt-get install -y fonts-liberation fonts-noto-cjk" |
| 102 | ) |
| 103 | else: |
| 104 | logger.debug(f"Fontconfig detected {len(result.stdout.splitlines())} fonts") |
| 105 | |
| 106 | except FileNotFoundError: |
| 107 | logger.warning( |
| 108 | "fontconfig (fc-list) not found on system. " |
| 109 | "Install with: sudo apt-get install -y fontconfig" |
| 110 | ) |
| 111 | except Exception as e: |
| 112 | logger.debug(f"Could not check fontconfig status: {e}") |
| 113 | |
| 114 | def _load_template(self, template_path: str) -> str: |
| 115 | """Load HTML template from file""" |
| 116 | path = Path(template_path) |
| 117 | if not path.exists(): |
| 118 | raise FileNotFoundError(f"Template not found: {template_path}") |
| 119 | |
| 120 | with open(path, 'r', encoding='utf-8') as f: |
| 121 | content = f.read() |
| 122 | |
| 123 | logger.debug(f"Template loaded: {len(content)} chars") |
| 124 | return content |
| 125 | |
| 126 | def _parse_media_size_from_meta(self) -> tuple[Optional[int], Optional[int]]: |
| 127 | """ |
| 128 | Parse media size from meta tags in template |
| 129 | |
| 130 | Looks for meta tags: |
| 131 | - <meta name="template:media-width" content="1024"> |
| 132 | - <meta name="template:media-height" content="1024"> |
| 133 | |
| 134 | Returns: |
| 135 | Tuple of (width, height) or (None, None) if not found |
| 136 | """ |
| 137 | from bs4 import BeautifulSoup |
| 138 | |
| 139 | try: |
| 140 | soup = BeautifulSoup(self.template, 'html.parser') |
| 141 | |
| 142 | width_meta = soup.find('meta', attrs={'name': 'template:media-width'}) |
| 143 | height_meta = soup.find('meta', attrs={'name': 'template:media-height'}) |
| 144 | |
| 145 | if width_meta and height_meta: |
| 146 | width = int(width_meta.get('content', 0)) |
| 147 | height = int(height_meta.get('content', 0)) |
| 148 | |
| 149 | if width > 0 and height > 0: |
| 150 | logger.debug(f"Found media size in meta tags: {width}x{height}") |
| 151 | return width, height |
| 152 | |
| 153 | return None, None |
| 154 | |
| 155 | except Exception as e: |
| 156 | logger.warning(f"Failed to parse media size from meta tags: {e}") |
| 157 | return None, None |
| 158 | |
| 159 | def get_media_size(self) -> tuple[int, int]: |
| 160 | """ |
| 161 | Get media size for image/video generation |
| 162 | |
| 163 | Returns media size specified in template meta tags. |
| 164 | |
| 165 | Returns: |
| 166 | Tuple of (width, height) |
| 167 | """ |
| 168 | media_width, media_height = self._parse_media_size_from_meta() |
| 169 | |
| 170 | if media_width and media_height: |
| 171 | return media_width, media_height |
| 172 | |
| 173 | logger.warning(f"No media size meta tags found in template {self.template_path}, using fallback 1024x1024") |
| 174 | return 1024, 1024 |
| 175 | |
| 176 | def parse_template_parameters(self) -> Dict[str, Dict[str, Any]]: |
| 177 | """ |
| 178 | Parse custom parameters from HTML template |
| 179 | |
| 180 | Supports syntax: {{param:type=default}} |
| 181 | - {{param}} -> text type, no default |
| 182 | - {{param=value}} -> text type, with default |
| 183 | - {{param:type}} -> specified type, no default |
| 184 | - {{param:type=value}} -> specified type, with default |
| 185 | |
| 186 | Supported types: text, number, color, bool |
| 187 | |
| 188 | Returns: |
| 189 | Dictionary of custom parameters with their configurations: |
| 190 | { |
| 191 | 'param_name': { |
| 192 | 'type': 'text' | 'number' | 'color' | 'bool', |
| 193 | 'default': Any, |
| 194 | 'label': str # same as param_name |
| 195 | } |
| 196 | } |
| 197 | """ |
| 198 | PRESET_PARAMS = {'title', 'text', 'image', 'index'} |
| 199 | |
| 200 | PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}' |
| 201 | |
| 202 | params = {} |
| 203 | |
| 204 | for match in re.finditer(PARAM_PATTERN, self.template): |
| 205 | param_name = match.group(1) |
| 206 | param_type = match.group(2) or 'text' |
| 207 | default_value = match.group(3) |
| 208 | |
| 209 | if param_name in PRESET_PARAMS: |
| 210 | continue |
| 211 | |
| 212 | if param_name in params: |
| 213 | continue |
| 214 | |
| 215 | if param_type not in {'text', 'number', 'color', 'bool'}: |
| 216 | logger.warning(f"Unknown parameter type '{param_type}' for '{param_name}', defaulting to 'text'") |
| 217 | param_type = 'text' |
| 218 | |
| 219 | parsed_default = self._parse_default_value(param_type, default_value) |
| 220 | |
| 221 | params[param_name] = { |
| 222 | 'type': param_type, |
| 223 | 'default': parsed_default, |
| 224 | 'label': param_name, |
| 225 | } |
| 226 | |
| 227 | if params: |
| 228 | logger.debug(f"Parsed {len(params)} custom parameter(s) from template: {list(params.keys())}") |
| 229 | |
| 230 | return params |
| 231 | |
| 232 | def _parse_default_value(self, param_type: str, value_str: Optional[str]) -> Any: |
| 233 | """ |
| 234 | Parse default value based on parameter type |
| 235 | |
| 236 | Args: |
| 237 | param_type: Type of parameter (text, number, color, bool) |
| 238 | value_str: String value to parse (can be None) |
| 239 | |
| 240 | Returns: |
| 241 | Parsed value with appropriate type |
| 242 | """ |
| 243 | if value_str is None: |
| 244 | return { |
| 245 | 'text': '', |
| 246 | 'number': 0, |
| 247 | 'color': '#000000', |
| 248 | 'bool': False, |
| 249 | }.get(param_type, '') |
| 250 | |
| 251 | if param_type == 'number': |
| 252 | try: |
| 253 | if '.' in value_str: |
| 254 | return float(value_str) |
| 255 | else: |
| 256 | return int(value_str) |
| 257 | except ValueError: |
| 258 | logger.warning(f"Invalid number value '{value_str}', using 0") |
| 259 | return 0 |
| 260 | |
| 261 | elif param_type == 'bool': |
| 262 | return value_str.lower() in {'true', '1', 'yes', 'on'} |
| 263 | |
| 264 | elif param_type == 'color': |
| 265 | if value_str.startswith('#'): |
| 266 | return value_str |
| 267 | else: |
| 268 | return f'#{value_str}' |
| 269 | |
| 270 | else: # text |
| 271 | return value_str |
| 272 | |
| 273 | def _replace_parameters(self, html: str, values: Dict[str, Any]) -> str: |
| 274 | """ |
| 275 | Replace parameter placeholders with actual values |
| 276 | |
| 277 | Supports DSL syntax: {{param:type=default}} |
| 278 | - If value provided in values dict, use it |
| 279 | - Otherwise, use default value from placeholder |
| 280 | - If no default, use empty string |
| 281 | |
| 282 | Args: |
| 283 | html: HTML template content |
| 284 | values: Dictionary of parameter values |
| 285 | |
| 286 | Returns: |
| 287 | HTML with placeholders replaced |
| 288 | """ |
| 289 | PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}' |
| 290 | |
| 291 | def replacer(match): |
| 292 | param_name = match.group(1) |
| 293 | param_type = match.group(2) or 'text' |
| 294 | default_value_str = match.group(3) |
| 295 | |
| 296 | if param_name in values: |
| 297 | value = values[param_name] |
| 298 | if isinstance(value, bool): |
| 299 | return 'true' if value else 'false' |
| 300 | return str(value) if value is not None else '' |
| 301 | |
| 302 | elif default_value_str: |
| 303 | return default_value_str |
| 304 | |
| 305 | else: |
| 306 | return '' |
| 307 | |
| 308 | return re.sub(PARAM_PATTERN, replacer, html) |
| 309 | |
| 310 | @classmethod |
| 311 | async def _ensure_browser(cls): |
| 312 | """Lazily initialize a shared Playwright browser instance""" |
| 313 | current_loop = asyncio.get_running_loop() |
| 314 | browser_usable = ( |
| 315 | cls._browser is not None |
| 316 | and cls._browser_loop is current_loop |
| 317 | and cls._browser.is_connected() |
| 318 | ) |
| 319 | |
| 320 | if not browser_usable: |
| 321 | if cls._browser is not None and cls._browser_loop is not current_loop: |
| 322 | logger.warning( |
| 323 | "Detected cross-loop Playwright browser reuse attempt; " |
| 324 | "recreating browser for current event loop" |
| 325 | ) |
| 326 | |
| 327 | cls._browser = None |
| 328 | cls._playwright = None |
| 329 | from playwright.async_api import async_playwright |
| 330 | cls._playwright = await async_playwright().start() |
| 331 | cls._browser = await cls._playwright.chromium.launch( |
| 332 | args=[ |
| 333 | '--no-sandbox', |
| 334 | '--disable-dev-shm-usage', |
| 335 | '--disable-gpu', |
| 336 | '--disable-extensions', |
| 337 | ] |
| 338 | ) |
| 339 | cls._browser_loop = current_loop |
| 340 | logger.debug("Initialized Playwright Chromium browser") |
| 341 | return cls._browser |
| 342 | |
| 343 | @classmethod |
| 344 | def _discard_browser_references(cls): |
| 345 | """Drop stale Playwright objects that belong to another event loop.""" |
| 346 | cls._browser = None |
| 347 | cls._playwright = None |
| 348 | cls._browser_loop_id = None |
| 349 | |
| 350 | @classmethod |
| 351 | async def _reset_browser(cls): |
| 352 | """Best-effort reset for stale or broken Playwright connections.""" |
| 353 | if cls._browser: |
| 354 | try: |
| 355 | if cls._browser.is_connected(): |
| 356 | await asyncio.wait_for(cls._browser.close(), timeout=5) |
| 357 | except Exception as e: |
| 358 | logger.debug(f"Ignoring error while closing stale browser: {e}") |
| 359 | finally: |
| 360 | cls._browser = None |
| 361 | |
| 362 | if cls._playwright: |
| 363 | try: |
| 364 | await asyncio.wait_for(cls._playwright.stop(), timeout=5) |
| 365 | except Exception as e: |
| 366 | logger.debug(f"Ignoring error while stopping stale Playwright: {e}") |
| 367 | finally: |
| 368 | cls._playwright = None |
| 369 | cls._browser_loop_id = None |
| 370 | |
| 371 | @classmethod |
| 372 | async def close_browser(cls): |
| 373 | """Shutdown the shared browser instance (call on app teardown)""" |
| 374 | if cls._browser: |
| 375 | await cls._browser.close() |
| 376 | cls._browser = None |
| 377 | cls._browser_loop = None |
| 378 | if cls._playwright: |
| 379 | await cls._playwright.stop() |
| 380 | cls._playwright = None |
| 381 | logger.debug("Playwright browser closed") |
| 382 | |
| 383 | async def generate_frame( |
| 384 | self, |
| 385 | title: str, |
| 386 | text: str, |
| 387 | image: str, |
| 388 | ext: Optional[Dict[str, Any]] = None, |
| 389 | output_path: Optional[str] = None |
| 390 | ) -> str: |
| 391 | """ |
| 392 | Generate frame from HTML template |
| 393 | |
| 394 | Video size is automatically determined from template path during initialization. |
| 395 | |
| 396 | Args: |
| 397 | title: Video title |
| 398 | text: Narration text for this frame |
| 399 | image: Path to AI-generated image (supports relative path, absolute path, or HTTP URL) |
| 400 | ext: Additional data (content_title, content_author, etc.) |
| 401 | output_path: Custom output path (auto-generated if None) |
| 402 | |
| 403 | Returns: |
| 404 | Path to generated frame image |
| 405 | """ |
| 406 | if image and not image.startswith(('http://', 'https://', 'data:', 'file://')): |
| 407 | image_path = Path(image) |
| 408 | if not image_path.is_absolute(): |
| 409 | image_path = Path.cwd() / image |
| 410 | |
| 411 | if not image_path.exists(): |
| 412 | logger.warning(f"Image file not found: {image_path}") |
| 413 | else: |
| 414 | image = image_path.as_uri() |
| 415 | logger.debug(f"Converted image path to: {image}") |
| 416 | |
| 417 | context = { |
| 418 | "title": title, |
| 419 | "text": text, |
| 420 | "image": image, |
| 421 | } |
| 422 | |
| 423 | if ext: |
| 424 | context.update(ext) |
| 425 | |
| 426 | html = self._replace_parameters(self.template, context) |
| 427 | |
| 428 | if output_path is None: |
| 429 | from pixelle_video.utils.os_util import get_output_path |
| 430 | output_filename = f"frame_{uuid.uuid4().hex[:16]}.png" |
| 431 | output_path = get_output_path(output_filename) |
| 432 | else: |
| 433 | os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| 434 | |
| 435 | logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})") |
| 436 | tmp_html_path = None |
| 437 | page = None |
| 438 | try: |
| 439 | try: |
| 440 | browser = await self._ensure_browser() |
| 441 | page = await browser.new_page( |
| 442 | viewport={'width': self.width, 'height': self.height}, |
| 443 | device_scale_factor=1, |
| 444 | ) |
| 445 | except Exception as e: |
| 446 | logger.warning(f"Playwright browser connection failed, restarting once: {e}") |
| 447 | await self._reset_browser() |
| 448 | browser = await self._ensure_browser() |
| 449 | page = await browser.new_page( |
| 450 | viewport={'width': self.width, 'height': self.height}, |
| 451 | device_scale_factor=1, |
| 452 | ) |
| 453 | |
| 454 | try: |
| 455 | # Write HTML to a temp file and navigate via file:// URL so that |
| 456 | # local file:// image references are loaded under the same origin. |
| 457 | fd, tmp_html_path = tempfile.mkstemp(suffix='.html', prefix='pv_frame_') |
| 458 | with os.fdopen(fd, 'w', encoding='utf-8') as f: |
| 459 | f.write(html) |
| 460 | |
| 461 | await page.goto(Path(tmp_html_path).as_uri(), wait_until='networkidle') |
| 462 | await page.screenshot(path=output_path, type='png', omit_background=True) |
| 463 | finally: |
| 464 | if page: |
| 465 | await page.close() |
| 466 | if tmp_html_path and os.path.exists(tmp_html_path): |
| 467 | os.unlink(tmp_html_path) |
| 468 | |
| 469 | logger.info(f"Frame generated: {output_path}") |
| 470 | return output_path |
| 471 | |
| 472 | except Exception as e: |
| 473 | logger.exception("Failed to render HTML template") |
| 474 | raise RuntimeError( |
| 475 | f"HTML rendering failed: {type(e).__name__}: {e}" |
| 476 | ) from e |
| 477 |