| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435 |
- # 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 using Playwright for headless browser rendering.
- 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
-
- Playwright browser install: playwright install --with-deps chromium
- """
- import asyncio
- import os
- import re
- import tempfile
- import uuid
- from typing import Dict, Any, Optional
- from pathlib import Path
- 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.
- Uses Playwright for reliable headless browser rendering.
-
- 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"}
- ... )
- """
-
- _browser = None
- _playwright = None
- _browser_loop = None
- 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._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
-
- 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:
- - <meta name="template:media-width" content="1024">
- - <meta name="template:media-height" content="1024">
-
- Returns:
- Tuple of (width, height) or (None, None) if not found
- """
- from bs4 import BeautifulSoup
-
- try:
- soup = BeautifulSoup(self.template, 'html.parser')
-
- 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
-
- 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_PARAMS = {'title', 'text', 'image', 'index'}
-
- 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_value = match.group(3)
-
- if param_name in PRESET_PARAMS:
- continue
-
- if param_name in params:
- continue
-
- 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'
-
- parsed_default = self._parse_default_value(param_type, default_value)
-
- params[param_name] = {
- 'type': param_type,
- 'default': parsed_default,
- 'label': param_name,
- }
-
- 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:
- return {
- 'text': '',
- 'number': 0,
- 'color': '#000000',
- 'bool': False,
- }.get(param_type, '')
-
- if param_type == 'number':
- try:
- 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':
- return value_str.lower() in {'true', '1', 'yes', 'on'}
-
- elif param_type == 'color':
- 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)
-
- if param_name in values:
- value = values[param_name]
- if isinstance(value, bool):
- return 'true' if value else 'false'
- return str(value) if value is not None else ''
-
- elif default_value_str:
- return default_value_str
-
- else:
- return ''
-
- return re.sub(PARAM_PATTERN, replacer, html)
- @classmethod
- async def _ensure_browser(cls):
- """Lazily initialize a shared Playwright browser instance"""
- current_loop = asyncio.get_running_loop()
- browser_usable = (
- cls._browser is not None
- and cls._browser_loop is current_loop
- and cls._browser.is_connected()
- )
- if not browser_usable:
- if cls._browser is not None and cls._browser_loop is not current_loop:
- logger.warning(
- "Detected cross-loop Playwright browser reuse attempt; "
- "recreating browser for current event loop"
- )
- cls._browser = None
- cls._playwright = None
- from playwright.async_api import async_playwright
- cls._playwright = await async_playwright().start()
- cls._browser = await cls._playwright.chromium.launch(
- args=[
- '--no-sandbox',
- '--disable-dev-shm-usage',
- '--disable-gpu',
- '--disable-extensions',
- ]
- )
- cls._browser_loop = current_loop
- logger.debug("Initialized Playwright Chromium browser")
- return cls._browser
- @classmethod
- async def close_browser(cls):
- """Shutdown the shared browser instance (call on app teardown)"""
- if cls._browser:
- await cls._browser.close()
- cls._browser = None
- cls._browser_loop = None
- if cls._playwright:
- await cls._playwright.stop()
- cls._playwright = None
- logger.debug("Playwright browser closed")
- 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
- """
- if image and not image.startswith(('http://', 'https://', 'data:', 'file://')):
- image_path = Path(image)
- if not image_path.is_absolute():
- image_path = Path.cwd() / image
-
- if not image_path.exists():
- logger.warning(f"Image file not found: {image_path}")
- else:
- image = image_path.as_uri()
- logger.debug(f"Converted image path to: {image}")
-
- context = {
- "title": title,
- "text": text,
- "image": image,
- }
-
- if ext:
- context.update(ext)
-
- html = self._replace_parameters(self.template, context)
- if output_path is None:
- 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:
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
-
- logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})")
- tmp_html_path = None
- try:
- browser = await self._ensure_browser()
- page = await browser.new_page(
- viewport={'width': self.width, 'height': self.height},
- device_scale_factor=1,
- )
- try:
- # Write HTML to a temp file and navigate via file:// URL so that
- # local file:// image references are loaded under the same origin.
- fd, tmp_html_path = tempfile.mkstemp(suffix='.html', prefix='pv_frame_')
- with os.fdopen(fd, 'w', encoding='utf-8') as f:
- f.write(html)
-
- await page.goto(Path(tmp_html_path).as_uri(), wait_until='networkidle')
- await page.screenshot(path=output_path, type='png', omit_background=True)
- finally:
- await page.close()
- if tmp_html_path and os.path.exists(tmp_html_path):
- os.unlink(tmp_html_path)
-
- logger.info(f"Frame generated: {output_path}")
- return output_path
-
- except Exception as e:
- logger.exception("Failed to render HTML template")
- raise RuntimeError(
- f"HTML rendering failed: {type(e).__name__}: {e}"
- ) from e
|