frame_html.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  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. HTML-based Frame Generator Service
  14. Renders HTML templates to frame images using Playwright for headless browser rendering.
  15. Linux Environment Requirements:
  16. - fontconfig package must be installed
  17. - Basic fonts (e.g., fonts-liberation, fonts-noto) recommended
  18. Ubuntu/Debian: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk
  19. CentOS/RHEL: sudo yum install -y fontconfig liberation-fonts google-noto-cjk-fonts
  20. Playwright browser install: playwright install --with-deps chromium
  21. """
  22. import asyncio
  23. import os
  24. import re
  25. import tempfile
  26. import uuid
  27. from typing import Dict, Any, Optional
  28. from pathlib import Path
  29. from loguru import logger
  30. from pixelle_video.utils.template_util import parse_template_size
  31. class HTMLFrameGenerator:
  32. """
  33. HTML-based frame generator
  34. Renders HTML templates to frame images with variable substitution.
  35. Uses Playwright for reliable headless browser rendering.
  36. Usage:
  37. >>> generator = HTMLFrameGenerator("templates/modern.html")
  38. >>> frame_path = await generator.generate_frame(
  39. ... topic="Why reading matters",
  40. ... text="Reading builds new neural pathways...",
  41. ... image="/path/to/image.png",
  42. ... ext={"content_title": "Sample Title", "content_author": "Author Name"}
  43. ... )
  44. """
  45. _browser = None
  46. _playwright = None
  47. _browser_loop = None
  48. def __init__(self, template_path: str):
  49. """
  50. Initialize HTML frame generator
  51. Args:
  52. template_path: Path to HTML template file (e.g., "templates/1080x1920/default.html")
  53. """
  54. self.template_path = template_path
  55. self.template = self._load_template(template_path)
  56. # Parse video size from template path
  57. self.width, self.height = parse_template_size(template_path)
  58. self._check_linux_dependencies()
  59. logger.debug(f"Loaded HTML template: {template_path} (size: {self.width}x{self.height})")
  60. def _check_linux_dependencies(self):
  61. """Check Linux system dependencies and warn if missing"""
  62. if os.name != 'posix':
  63. return
  64. try:
  65. import subprocess
  66. result = subprocess.run(
  67. ['fc-list'],
  68. capture_output=True,
  69. timeout=2
  70. )
  71. if result.returncode != 0:
  72. logger.warning(
  73. "fontconfig not found or not working properly. "
  74. "Install with: sudo apt-get install -y fontconfig fonts-liberation fonts-noto-cjk"
  75. )
  76. elif not result.stdout:
  77. logger.warning(
  78. "No fonts detected by fontconfig. "
  79. "Install fonts with: sudo apt-get install -y fonts-liberation fonts-noto-cjk"
  80. )
  81. else:
  82. logger.debug(f"Fontconfig detected {len(result.stdout.splitlines())} fonts")
  83. except FileNotFoundError:
  84. logger.warning(
  85. "fontconfig (fc-list) not found on system. "
  86. "Install with: sudo apt-get install -y fontconfig"
  87. )
  88. except Exception as e:
  89. logger.debug(f"Could not check fontconfig status: {e}")
  90. def _load_template(self, template_path: str) -> str:
  91. """Load HTML template from file"""
  92. path = Path(template_path)
  93. if not path.exists():
  94. raise FileNotFoundError(f"Template not found: {template_path}")
  95. with open(path, 'r', encoding='utf-8') as f:
  96. content = f.read()
  97. logger.debug(f"Template loaded: {len(content)} chars")
  98. return content
  99. def _parse_media_size_from_meta(self) -> tuple[Optional[int], Optional[int]]:
  100. """
  101. Parse media size from meta tags in template
  102. Looks for meta tags:
  103. - <meta name="template:media-width" content="1024">
  104. - <meta name="template:media-height" content="1024">
  105. Returns:
  106. Tuple of (width, height) or (None, None) if not found
  107. """
  108. from bs4 import BeautifulSoup
  109. try:
  110. soup = BeautifulSoup(self.template, 'html.parser')
  111. width_meta = soup.find('meta', attrs={'name': 'template:media-width'})
  112. height_meta = soup.find('meta', attrs={'name': 'template:media-height'})
  113. if width_meta and height_meta:
  114. width = int(width_meta.get('content', 0))
  115. height = int(height_meta.get('content', 0))
  116. if width > 0 and height > 0:
  117. logger.debug(f"Found media size in meta tags: {width}x{height}")
  118. return width, height
  119. return None, None
  120. except Exception as e:
  121. logger.warning(f"Failed to parse media size from meta tags: {e}")
  122. return None, None
  123. def get_media_size(self) -> tuple[int, int]:
  124. """
  125. Get media size for image/video generation
  126. Returns media size specified in template meta tags.
  127. Returns:
  128. Tuple of (width, height)
  129. """
  130. media_width, media_height = self._parse_media_size_from_meta()
  131. if media_width and media_height:
  132. return media_width, media_height
  133. logger.warning(f"No media size meta tags found in template {self.template_path}, using fallback 1024x1024")
  134. return 1024, 1024
  135. def parse_template_parameters(self) -> Dict[str, Dict[str, Any]]:
  136. """
  137. Parse custom parameters from HTML template
  138. Supports syntax: {{param:type=default}}
  139. - {{param}} -> text type, no default
  140. - {{param=value}} -> text type, with default
  141. - {{param:type}} -> specified type, no default
  142. - {{param:type=value}} -> specified type, with default
  143. Supported types: text, number, color, bool
  144. Returns:
  145. Dictionary of custom parameters with their configurations:
  146. {
  147. 'param_name': {
  148. 'type': 'text' | 'number' | 'color' | 'bool',
  149. 'default': Any,
  150. 'label': str # same as param_name
  151. }
  152. }
  153. """
  154. PRESET_PARAMS = {'title', 'text', 'image', 'index'}
  155. PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}'
  156. params = {}
  157. for match in re.finditer(PARAM_PATTERN, self.template):
  158. param_name = match.group(1)
  159. param_type = match.group(2) or 'text'
  160. default_value = match.group(3)
  161. if param_name in PRESET_PARAMS:
  162. continue
  163. if param_name in params:
  164. continue
  165. if param_type not in {'text', 'number', 'color', 'bool'}:
  166. logger.warning(f"Unknown parameter type '{param_type}' for '{param_name}', defaulting to 'text'")
  167. param_type = 'text'
  168. parsed_default = self._parse_default_value(param_type, default_value)
  169. params[param_name] = {
  170. 'type': param_type,
  171. 'default': parsed_default,
  172. 'label': param_name,
  173. }
  174. if params:
  175. logger.debug(f"Parsed {len(params)} custom parameter(s) from template: {list(params.keys())}")
  176. return params
  177. def _parse_default_value(self, param_type: str, value_str: Optional[str]) -> Any:
  178. """
  179. Parse default value based on parameter type
  180. Args:
  181. param_type: Type of parameter (text, number, color, bool)
  182. value_str: String value to parse (can be None)
  183. Returns:
  184. Parsed value with appropriate type
  185. """
  186. if value_str is None:
  187. return {
  188. 'text': '',
  189. 'number': 0,
  190. 'color': '#000000',
  191. 'bool': False,
  192. }.get(param_type, '')
  193. if param_type == 'number':
  194. try:
  195. if '.' in value_str:
  196. return float(value_str)
  197. else:
  198. return int(value_str)
  199. except ValueError:
  200. logger.warning(f"Invalid number value '{value_str}', using 0")
  201. return 0
  202. elif param_type == 'bool':
  203. return value_str.lower() in {'true', '1', 'yes', 'on'}
  204. elif param_type == 'color':
  205. if value_str.startswith('#'):
  206. return value_str
  207. else:
  208. return f'#{value_str}'
  209. else: # text
  210. return value_str
  211. def _replace_parameters(self, html: str, values: Dict[str, Any]) -> str:
  212. """
  213. Replace parameter placeholders with actual values
  214. Supports DSL syntax: {{param:type=default}}
  215. - If value provided in values dict, use it
  216. - Otherwise, use default value from placeholder
  217. - If no default, use empty string
  218. Args:
  219. html: HTML template content
  220. values: Dictionary of parameter values
  221. Returns:
  222. HTML with placeholders replaced
  223. """
  224. PARAM_PATTERN = r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-z]+))?(?:=([^}]+))?\}\}'
  225. def replacer(match):
  226. param_name = match.group(1)
  227. param_type = match.group(2) or 'text'
  228. default_value_str = match.group(3)
  229. if param_name in values:
  230. value = values[param_name]
  231. if isinstance(value, bool):
  232. return 'true' if value else 'false'
  233. return str(value) if value is not None else ''
  234. elif default_value_str:
  235. return default_value_str
  236. else:
  237. return ''
  238. return re.sub(PARAM_PATTERN, replacer, html)
  239. @classmethod
  240. async def _ensure_browser(cls):
  241. """Lazily initialize a shared Playwright browser instance"""
  242. current_loop = asyncio.get_running_loop()
  243. browser_usable = (
  244. cls._browser is not None
  245. and cls._browser_loop is current_loop
  246. and cls._browser.is_connected()
  247. )
  248. if not browser_usable:
  249. if cls._browser is not None and cls._browser_loop is not current_loop:
  250. logger.warning(
  251. "Detected cross-loop Playwright browser reuse attempt; "
  252. "recreating browser for current event loop"
  253. )
  254. cls._browser = None
  255. cls._playwright = None
  256. from playwright.async_api import async_playwright
  257. cls._playwright = await async_playwright().start()
  258. cls._browser = await cls._playwright.chromium.launch(
  259. args=[
  260. '--no-sandbox',
  261. '--disable-dev-shm-usage',
  262. '--disable-gpu',
  263. '--disable-extensions',
  264. ]
  265. )
  266. cls._browser_loop = current_loop
  267. logger.debug("Initialized Playwright Chromium browser")
  268. return cls._browser
  269. @classmethod
  270. async def close_browser(cls):
  271. """Shutdown the shared browser instance (call on app teardown)"""
  272. if cls._browser:
  273. await cls._browser.close()
  274. cls._browser = None
  275. cls._browser_loop = None
  276. if cls._playwright:
  277. await cls._playwright.stop()
  278. cls._playwright = None
  279. logger.debug("Playwright browser closed")
  280. async def generate_frame(
  281. self,
  282. title: str,
  283. text: str,
  284. image: str,
  285. ext: Optional[Dict[str, Any]] = None,
  286. output_path: Optional[str] = None
  287. ) -> str:
  288. """
  289. Generate frame from HTML template
  290. Video size is automatically determined from template path during initialization.
  291. Args:
  292. title: Video title
  293. text: Narration text for this frame
  294. image: Path to AI-generated image (supports relative path, absolute path, or HTTP URL)
  295. ext: Additional data (content_title, content_author, etc.)
  296. output_path: Custom output path (auto-generated if None)
  297. Returns:
  298. Path to generated frame image
  299. """
  300. if image and not image.startswith(('http://', 'https://', 'data:', 'file://')):
  301. image_path = Path(image)
  302. if not image_path.is_absolute():
  303. image_path = Path.cwd() / image
  304. if not image_path.exists():
  305. logger.warning(f"Image file not found: {image_path}")
  306. else:
  307. image = image_path.as_uri()
  308. logger.debug(f"Converted image path to: {image}")
  309. context = {
  310. "title": title,
  311. "text": text,
  312. "image": image,
  313. }
  314. if ext:
  315. context.update(ext)
  316. html = self._replace_parameters(self.template, context)
  317. if output_path is None:
  318. from pixelle_video.utils.os_util import get_output_path
  319. output_filename = f"frame_{uuid.uuid4().hex[:16]}.png"
  320. output_path = get_output_path(output_filename)
  321. else:
  322. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  323. logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})")
  324. tmp_html_path = None
  325. try:
  326. browser = await self._ensure_browser()
  327. page = await browser.new_page(
  328. viewport={'width': self.width, 'height': self.height},
  329. device_scale_factor=1,
  330. )
  331. try:
  332. # Write HTML to a temp file and navigate via file:// URL so that
  333. # local file:// image references are loaded under the same origin.
  334. fd, tmp_html_path = tempfile.mkstemp(suffix='.html', prefix='pv_frame_')
  335. with os.fdopen(fd, 'w', encoding='utf-8') as f:
  336. f.write(html)
  337. await page.goto(Path(tmp_html_path).as_uri(), wait_until='networkidle')
  338. await page.screenshot(path=output_path, type='png', omit_background=True)
  339. finally:
  340. await page.close()
  341. if tmp_html_path and os.path.exists(tmp_html_path):
  342. os.unlink(tmp_html_path)
  343. logger.info(f"Frame generated: {output_path}")
  344. return output_path
  345. except Exception as e:
  346. logger.exception("Failed to render HTML template")
  347. raise RuntimeError(
  348. f"HTML rendering failed: {type(e).__name__}: {e}"
  349. ) from e