Files
AI-Video/pixelle_video/services/frame_html.py

534 lines
21 KiB
Python

# 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:
- <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')
# 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
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 = [
'--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}")