template_util.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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. Template utility functions for size parsing and template management
  14. """
  15. import os
  16. from pathlib import Path
  17. from typing import List, Tuple, Optional, Literal
  18. from pydantic import BaseModel, Field
  19. import logging
  20. from pixelle_video.utils.os_util import (
  21. get_resource_path,
  22. list_resource_files,
  23. list_resource_dirs,
  24. resource_exists
  25. )
  26. logger = logging.getLogger(__name__)
  27. def parse_template_size(template_path: str) -> Tuple[int, int]:
  28. """
  29. Parse video size from template path
  30. Args:
  31. template_path: Template path like "templates/1080x1920/default.html"
  32. or "1080x1920/default.html"
  33. Returns:
  34. Tuple of (width, height) in pixels
  35. Raises:
  36. ValueError: If template path format is invalid
  37. Examples:
  38. >>> parse_template_size("templates/1080x1920/default.html")
  39. (1080, 1920)
  40. >>> parse_template_size("1920x1080/modern.html")
  41. (1920, 1080)
  42. """
  43. path = Path(template_path)
  44. # Get parent directory name (should be like "1080x1920")
  45. dir_name = path.parent.name
  46. # Special case: if parent is "templates", go up one more level
  47. if dir_name == "templates":
  48. # This shouldn't happen in new structure, but handle it
  49. raise ValueError(
  50. f"Invalid template path format: {template_path}. "
  51. f"Expected format: 'WIDTHxHEIGHT/template.html' or 'templates/WIDTHxHEIGHT/template.html'"
  52. )
  53. # Parse size from directory name
  54. if 'x' not in dir_name:
  55. raise ValueError(
  56. f"Invalid size format in path: {template_path}. "
  57. f"Directory name should be 'WIDTHxHEIGHT' (e.g., '1080x1920')"
  58. )
  59. try:
  60. width_str, height_str = dir_name.split('x')
  61. width = int(width_str)
  62. height = int(height_str)
  63. # Sanity check
  64. if width < 100 or height < 100 or width > 10000 or height > 10000:
  65. raise ValueError(f"Invalid size dimensions: {width}x{height}")
  66. return (width, height)
  67. except ValueError as e:
  68. raise ValueError(
  69. f"Failed to parse size from path: {template_path}. "
  70. f"Expected format: 'WIDTHxHEIGHT/template.html' (e.g., '1080x1920/default.html'). "
  71. f"Error: {e}"
  72. )
  73. def list_available_sizes() -> List[str]:
  74. """
  75. List all available video sizes (merged from templates/ and data/templates/)
  76. Returns:
  77. List of size strings like ["1080x1920", "1920x1080", "1080x1080"]
  78. Examples:
  79. >>> list_available_sizes()
  80. ['1080x1920', '1920x1080', '1080x1080']
  81. """
  82. # Use new resource API to merge default and custom directories
  83. all_dirs = list_resource_dirs("templates")
  84. # Filter to only valid size formats (WIDTHxHEIGHT)
  85. sizes = []
  86. for dir_name in all_dirs:
  87. if 'x' in dir_name:
  88. try:
  89. width, height = dir_name.split('x')
  90. int(width)
  91. int(height)
  92. sizes.append(dir_name)
  93. except (ValueError, AttributeError):
  94. # Skip invalid directories
  95. continue
  96. return sorted(sizes)
  97. def list_templates_for_size(size: str) -> List[str]:
  98. """
  99. List all templates available for a given size (merged from templates/ and data/templates/)
  100. Args:
  101. size: Size string like "1080x1920"
  102. Returns:
  103. List of template filenames (without path) like ["default.html", "modern.html"]
  104. Examples:
  105. >>> list_templates_for_size("1080x1920")
  106. ['cartoon.html', 'default.html', 'elegant.html', 'modern.html', ...]
  107. """
  108. # Use new resource API to merge default and custom templates
  109. all_files = list_resource_files("templates", size)
  110. # Filter to only HTML files
  111. templates = [f for f in all_files if f.endswith('.html')]
  112. return sorted(templates)
  113. def get_template_full_path(size: str, template_name: str) -> str:
  114. """
  115. Get full template path from size and template name (checks data/templates/ first, then templates/)
  116. Args:
  117. size: Size string like "1080x1920"
  118. template_name: Template filename like "default.html"
  119. Returns:
  120. Full path like "templates/1080x1920/default.html" or "data/templates/1080x1920/default.html"
  121. Raises:
  122. FileNotFoundError: If template file doesn't exist in either location
  123. Examples:
  124. >>> get_template_full_path("1080x1920", "default.html")
  125. 'templates/1080x1920/default.html'
  126. """
  127. # Use new resource API to search custom first, then default
  128. try:
  129. return get_resource_path("templates", size, template_name)
  130. except FileNotFoundError:
  131. available_templates = list_templates_for_size(size)
  132. raise FileNotFoundError(
  133. f"Template not found: {size}/{template_name}\n"
  134. f"Available templates for size {size}: {available_templates}"
  135. )
  136. class TemplateDisplayInfo(BaseModel):
  137. """Template display information for UI layer"""
  138. name: str = Field(..., description="Template name without extension")
  139. size: str = Field(..., description="Size string like '1080x1920'")
  140. width: int = Field(..., description="Width in pixels")
  141. height: int = Field(..., description="Height in pixels")
  142. orientation: Literal['portrait', 'landscape', 'square'] = Field(
  143. ...,
  144. description="Video orientation"
  145. )
  146. is_standard: bool = Field(
  147. ...,
  148. description="True only for standard sizes: 1080x1920, 1920x1080, 1080x1080"
  149. )
  150. class TemplateInfo(BaseModel):
  151. """Complete template information with path and display info"""
  152. template_path: str = Field(..., description="Full template path like '1080x1920/default.html'")
  153. display_info: TemplateDisplayInfo = Field(..., description="Display information")
  154. def format_template_display_info(template_name: str, size: str) -> TemplateDisplayInfo:
  155. """
  156. Format template display information for UI
  157. Returns structured data for UI layer to handle display and i18n.
  158. Args:
  159. template_name: Template filename like "default.html"
  160. size: Size string like "1080x1920"
  161. Returns:
  162. TemplateDisplayInfo object with name, size, dimensions, orientation, and standard flag
  163. Examples:
  164. >>> info = format_template_display_info("default.html", "1080x1920")
  165. >>> info.name
  166. 'default'
  167. >>> info.is_standard
  168. True
  169. >>> info = format_template_display_info("custom.html", "1080x1921")
  170. >>> info.orientation
  171. 'portrait'
  172. >>> info.is_standard
  173. False
  174. """
  175. # Keep full template name with .html extension
  176. name = template_name
  177. # Parse size
  178. width, height = map(int, size.split('x'))
  179. # Detect orientation
  180. if height > width:
  181. orientation = 'portrait'
  182. elif width > height:
  183. orientation = 'landscape'
  184. else:
  185. orientation = 'square'
  186. # Check if it's a standard size (only these three)
  187. is_standard = (width, height) in [(1080, 1920), (1920, 1080), (1080, 1080)]
  188. return TemplateDisplayInfo(
  189. name=name,
  190. size=size,
  191. width=width,
  192. height=height,
  193. orientation=orientation,
  194. is_standard=is_standard
  195. )
  196. def get_all_templates_with_info() -> List[TemplateInfo]:
  197. """
  198. Get all templates with their display information
  199. Returns:
  200. List of TemplateInfo objects
  201. Example:
  202. >>> templates = get_all_templates_with_info()
  203. >>> for t in templates:
  204. ... print(f"{t.display_info.name} - {t.display_info.orientation}")
  205. ... print(f" Path: {t.template_path}")
  206. ... print(f" Standard: {t.display_info.is_standard}")
  207. """
  208. result = []
  209. sizes = list_available_sizes()
  210. for size in sizes:
  211. templates = list_templates_for_size(size)
  212. for template in templates:
  213. display_info = format_template_display_info(template, size)
  214. full_path = f"{size}/{template}"
  215. result.append(TemplateInfo(
  216. template_path=full_path,
  217. display_info=display_info
  218. ))
  219. return result
  220. def get_templates_grouped_by_size() -> dict:
  221. """
  222. Get templates grouped by size
  223. Returns:
  224. Dict with size as key, list of TemplateInfo as value
  225. Ordered by orientation priority: portrait > landscape > square
  226. Example:
  227. >>> grouped = get_templates_grouped_by_size()
  228. >>> for size, templates in grouped.items():
  229. ... print(f"Size: {size}")
  230. ... for t in templates:
  231. ... print(f" - {t.display_info.name}")
  232. """
  233. from collections import defaultdict
  234. templates = get_all_templates_with_info()
  235. grouped = defaultdict(list)
  236. for t in templates:
  237. grouped[t.display_info.size].append(t)
  238. # Sort groups by orientation priority: portrait > landscape > square
  239. orientation_priority = {'portrait': 0, 'landscape': 1, 'square': 2}
  240. sorted_grouped = {}
  241. for size in sorted(grouped.keys(), key=lambda s: (
  242. orientation_priority.get(grouped[s][0].display_info.orientation, 3),
  243. s
  244. )):
  245. sorted_grouped[size] = sorted(grouped[size], key=lambda t: t.display_info.name)
  246. return sorted_grouped
  247. def resolve_template_path(template_input: Optional[str]) -> str:
  248. """
  249. Resolve template input to full path with validation (checks data/templates/ first, then templates/)
  250. Args:
  251. template_input: Can be:
  252. - None: Use default "1080x1920/image_default.html"
  253. - "template.html": Use default size + this template
  254. - "1080x1920/template.html": Full relative path
  255. - "templates/1080x1920/template.html": Absolute-ish path (legacy)
  256. - "data/templates/1080x1920/template.html": Custom path (legacy)
  257. Returns:
  258. Resolved full path (custom if exists, otherwise default)
  259. Raises:
  260. FileNotFoundError: If template doesn't exist in either location
  261. Examples:
  262. >>> resolve_template_path(None)
  263. 'templates/1080x1920/image_default.html'
  264. >>> resolve_template_path("image_modern.html")
  265. 'templates/1080x1920/image_modern.html'
  266. >>> resolve_template_path("1920x1080/image_default.html")
  267. 'templates/1920x1080/image_default.html'
  268. """
  269. # Default case
  270. if template_input is None:
  271. template_input = "1080x1920/image_default.html"
  272. # Parse input to extract size and template name
  273. size = None
  274. template_name = None
  275. # Handle different input formats
  276. if template_input.startswith("templates/") or template_input.startswith("data/templates/"):
  277. # Legacy full path format - extract size and name
  278. parts = Path(template_input).parts
  279. if len(parts) >= 3:
  280. size = parts[-2]
  281. template_name = parts[-1]
  282. elif '/' in template_input and 'x' in template_input.split('/')[0]:
  283. # "1080x1920/template.html" format
  284. size, template_name = template_input.split('/', 1)
  285. else:
  286. # Just template name - use default size
  287. size = "1080x1920"
  288. template_name = template_input
  289. # Backward compatibility: migrate "default.html" to "image_default.html"
  290. if template_name == "default.html":
  291. migrated_name = "image_default.html"
  292. try:
  293. # Try migrated name first
  294. path = get_resource_path("templates", size, migrated_name)
  295. logger.info(f"Backward compatibility: migrated '{template_input}' to '{size}/{migrated_name}'")
  296. return path
  297. except FileNotFoundError:
  298. # Fall through to try original name
  299. logger.warning(f"Migrated template '{size}/{migrated_name}' not found, trying original name")
  300. # Use resource API to resolve path (custom > default)
  301. try:
  302. return get_resource_path("templates", size, template_name)
  303. except FileNotFoundError:
  304. available_sizes = list_available_sizes()
  305. raise FileNotFoundError(
  306. f"Template not found: {size}/{template_name}\n"
  307. f"Available sizes: {available_sizes}\n"
  308. f"Hint: Use format 'SIZExSIZE/template.html' (e.g., '1080x1920/image_default.html')"
  309. )
  310. def get_template_type(template_name: str) -> Literal['static', 'image', 'video']:
  311. """
  312. Detect template type from template filename
  313. Template naming convention:
  314. - static_*.html: Static style templates (no AI-generated media)
  315. - image_*.html: Templates requiring AI-generated images
  316. - video_*.html: Templates requiring AI-generated videos
  317. Args:
  318. template_name: Template filename like "image_default.html" or "video_simple.html"
  319. Returns:
  320. Template type: 'static', 'image', or 'video'
  321. Examples:
  322. >>> get_template_type("static_simple.html")
  323. 'static'
  324. >>> get_template_type("image_default.html")
  325. 'image'
  326. >>> get_template_type("video_simple.html")
  327. 'video'
  328. """
  329. name = Path(template_name).name
  330. if name.startswith("static_"):
  331. return "static"
  332. elif name.startswith("video_"):
  333. return "video"
  334. elif name.startswith("image_"):
  335. return "image"
  336. else:
  337. # Fallback: try to detect from legacy names
  338. logger.warning(
  339. f"Template '{template_name}' doesn't follow naming convention (static_/image_/video_). "
  340. f"Defaulting to 'image' type."
  341. )
  342. return "image"
  343. def filter_templates_by_type(
  344. templates: List[TemplateInfo],
  345. template_type: Literal['static', 'image', 'video']
  346. ) -> List[TemplateInfo]:
  347. """
  348. Filter templates by type
  349. Args:
  350. templates: List of TemplateInfo objects
  351. template_type: Type to filter by ('static', 'image', or 'video')
  352. Returns:
  353. Filtered list of TemplateInfo objects
  354. Examples:
  355. >>> all_templates = get_all_templates_with_info()
  356. >>> image_templates = filter_templates_by_type(all_templates, 'image')
  357. >>> len(image_templates) > 0
  358. True
  359. """
  360. filtered = []
  361. for t in templates:
  362. template_name = t.display_info.name
  363. if get_template_type(template_name) == template_type:
  364. filtered.append(t)
  365. return filtered
  366. def get_templates_grouped_by_size_and_type(
  367. template_type: Optional[Literal['static', 'image', 'video']] = None
  368. ) -> dict:
  369. """
  370. Get templates grouped by size, optionally filtered by type
  371. Args:
  372. template_type: Optional type filter ('static', 'image', or 'video')
  373. Returns:
  374. Dict with size as key, list of TemplateInfo as value
  375. Ordered by orientation priority: portrait > landscape > square
  376. Examples:
  377. >>> # Get all templates
  378. >>> all_grouped = get_templates_grouped_by_size_and_type()
  379. >>> # Get only image templates
  380. >>> image_grouped = get_templates_grouped_by_size_and_type('image')
  381. """
  382. from collections import defaultdict
  383. templates = get_all_templates_with_info()
  384. # Filter by type if specified
  385. if template_type is not None:
  386. templates = filter_templates_by_type(templates, template_type)
  387. grouped = defaultdict(list)
  388. for t in templates:
  389. grouped[t.display_info.size].append(t)
  390. # Sort groups by orientation priority: portrait > landscape > square
  391. orientation_priority = {'portrait': 0, 'landscape': 1, 'square': 2}
  392. sorted_grouped = {}
  393. for size in sorted(grouped.keys(), key=lambda s: (
  394. orientation_priority.get(grouped[s][0].display_info.orientation, 3),
  395. s
  396. )):
  397. sorted_grouped[size] = sorted(grouped[size], key=lambda t: t.display_info.name)
  398. return sorted_grouped