返回 Pixelle-Video
frame_html.py
根目录 / pixelle_video / services / frame_html.py
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
477 lines PYTHON