diff --git a/api/routers/video.py b/api/routers/video.py index 9119d40..d947101 100644 --- a/api/routers/video.py +++ b/api/routers/video.py @@ -65,8 +65,6 @@ async def generate_video_sync( image_width=request_body.image_width, image_height=request_body.image_height, image_workflow=request_body.image_workflow, - video_width=request_body.video_width, - video_height=request_body.video_height, video_fps=request_body.video_fps, frame_template=request_body.frame_template, prompt_prefix=request_body.prompt_prefix, diff --git a/api/schemas/video.py b/api/schemas/video.py index 1b1759b..715c3fd 100644 --- a/api/schemas/video.py +++ b/api/schemas/video.py @@ -37,12 +37,13 @@ class VideoGenerateRequest(BaseModel): image_workflow: Optional[str] = Field(None, description="Custom image workflow") # === Video Parameters === - video_width: int = Field(1080, description="Video width") - video_height: int = Field(1920, description="Video height") video_fps: int = Field(30, ge=15, le=60, description="Video FPS") - # === Frame Template === - frame_template: Optional[str] = Field(None, description="HTML template name (e.g., 'default.html')") + # === Frame Template (determines video size) === + frame_template: Optional[str] = Field( + None, + description="HTML template path with size (e.g., '1080x1920/default.html'). Video size is auto-determined from template." + ) # === Image Style === prompt_prefix: Optional[str] = Field(None, description="Image style prefix") diff --git a/pixelle_video/models/storyboard.py b/pixelle_video/models/storyboard.py index 6cb3a64..402c079 100644 --- a/pixelle_video/models/storyboard.py +++ b/pixelle_video/models/storyboard.py @@ -20,9 +20,7 @@ class StoryboardConfig: min_image_prompt_words: int = 30 # Min image prompt word count max_image_prompt_words: int = 60 # Max image prompt word count - # Video parameters - video_width: int = 1080 # Video width - video_height: int = 1920 # Video height (9:16 portrait) + # Video parameters (fps only, size is determined by frame template) video_fps: int = 30 # Frame rate # Audio parameters @@ -36,8 +34,8 @@ class StoryboardConfig: image_height: int = 1024 image_workflow: Optional[str] = None # Image workflow filename (None = use default) - # Frame template - frame_template: str = "default.html" # HTML template name or path (e.g., "default.html", "modern.html") + # Frame template (includes size information in path) + frame_template: str = "1080x1920/default.html" # Template path with size (e.g., "1080x1920/default.html") @dataclass diff --git a/pixelle_video/services/frame_html.py b/pixelle_video/services/frame_html.py index b35e173..567bfa8 100644 --- a/pixelle_video/services/frame_html.py +++ b/pixelle_video/services/frame_html.py @@ -18,6 +18,8 @@ from pathlib import Path from html2image import Html2Image from loguru import logger +from pixelle_video.utils.template_util import parse_template_size + class HTMLFrameGenerator: """ @@ -41,13 +43,17 @@ class HTMLFrameGenerator: Initialize HTML frame generator Args: - template_path: Path to HTML template file + 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}") + 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""" @@ -197,20 +203,18 @@ class HTMLFrameGenerator: text: str, image: str, ext: Optional[Dict[str, Any]] = None, - width: int = 1080, - height: int = 1920, 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.) - width: Frame width in pixels - height: Frame height in pixels output_path: Custom output path (auto-generated if None) Returns: @@ -266,11 +270,11 @@ class HTMLFrameGenerator: output_filename = os.path.basename(output_path) output_dir = os.path.dirname(output_path) - # Ensure Html2Image is initialized - self._ensure_hti(width, height) + # 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}") + logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})") try: self.hti.screenshot( html_str=html, diff --git a/pixelle_video/services/frame_processor.py b/pixelle_video/services/frame_processor.py index ad68825..c10127b 100644 --- a/pixelle_video/services/frame_processor.py +++ b/pixelle_video/services/frame_processor.py @@ -197,21 +197,10 @@ class FrameProcessor: ) -> str: """Compose frame using HTML template""" from pixelle_video.services.frame_html import HTMLFrameGenerator - from pathlib import Path + from pixelle_video.utils.template_util import resolve_template_path - # Resolve template path - template_filename = config.frame_template - - # Try templates/ directory first - template_path = Path(f"templates/{template_filename}") - if not template_path.exists(): - # Try as absolute/relative path - template_path = Path(template_filename) - if not template_path.exists(): - raise FileNotFoundError( - f"Template not found: {template_filename}. " - f"Built-in templates: default.html, modern.html, neon.html" - ) + # Resolve template path (handles various input formats) + template_path = resolve_template_path(config.frame_template) # Get content metadata from storyboard content_metadata = storyboard.content_metadata if storyboard else None @@ -224,15 +213,13 @@ class FrameProcessor: ext["content_subtitle"] = content_metadata.subtitle or "" ext["content_genre"] = content_metadata.genre or "" - # Generate frame using HTML - generator = HTMLFrameGenerator(str(template_path)) + # Generate frame using HTML (size is auto-parsed from template path) + generator = HTMLFrameGenerator(template_path) composed_path = await generator.generate_frame( title=storyboard.title, text=frame.narration, image=frame.image_path, ext=ext, - width=config.video_width, - height=config.video_height, output_path=output_path ) diff --git a/pixelle_video/services/video_generator.py b/pixelle_video/services/video_generator.py index eec2b25..f0ee4fb 100644 --- a/pixelle_video/services/video_generator.py +++ b/pixelle_video/services/video_generator.py @@ -72,11 +72,9 @@ class VideoGeneratorService: image_workflow: Optional[str] = None, # === Video Parameters === - video_width: int = 1080, - video_height: int = 1920, video_fps: int = 30, - # === Frame Template === + # === Frame Template (determines video size) === frame_template: Optional[str] = None, # === Image Style === @@ -128,12 +126,11 @@ class VideoGeneratorService: image_height: Generated image height (default 1024) image_workflow: Image workflow filename (e.g., "image_flux.json", None = use default) - video_width: Final video width (default 1080) - video_height: Final video height (default 1920) video_fps: Video frame rate (default 30) - frame_template: HTML template filename or path (None = use default template) - e.g., "default.html", "modern.html", "neon.html", or custom path + frame_template: HTML template path with size (None = use default "1080x1920/default.html") + Format: "SIZExSIZE/template.html" (e.g., "1080x1920/default.html", "1920x1080/modern.html") + Video size is automatically determined from template path prompt_prefix: Image prompt prefix (overrides config.yaml if provided) e.g., "anime style, vibrant colors" or "" for no prefix @@ -220,8 +217,6 @@ class VideoGeneratorService: max_narration_words=max_narration_words, min_image_prompt_words=min_image_prompt_words, max_image_prompt_words=max_image_prompt_words, - video_width=video_width, - video_height=video_height, video_fps=video_fps, voice_id=voice_id, tts_workflow=tts_workflow, @@ -230,7 +225,7 @@ class VideoGeneratorService: image_width=image_width, image_height=image_height, image_workflow=image_workflow, - frame_template=frame_template or "default.html" + frame_template=frame_template or "1080x1920/default.html" ) # Create storyboard diff --git a/pixelle_video/utils/template_util.py b/pixelle_video/utils/template_util.py new file mode 100644 index 0000000..372bf55 --- /dev/null +++ b/pixelle_video/utils/template_util.py @@ -0,0 +1,207 @@ +""" +Template utility functions for size parsing and template management +""" + +import os +from pathlib import Path +from typing import List, Tuple, Optional + + +def parse_template_size(template_path: str) -> Tuple[int, int]: + """ + Parse video size from template path + + Args: + template_path: Template path like "templates/1080x1920/default.html" + or "1080x1920/default.html" + + Returns: + Tuple of (width, height) in pixels + + Raises: + ValueError: If template path format is invalid + + Examples: + >>> parse_template_size("templates/1080x1920/default.html") + (1080, 1920) + >>> parse_template_size("1920x1080/modern.html") + (1920, 1080) + """ + path = Path(template_path) + + # Get parent directory name (should be like "1080x1920") + dir_name = path.parent.name + + # Special case: if parent is "templates", go up one more level + if dir_name == "templates": + # This shouldn't happen in new structure, but handle it + raise ValueError( + f"Invalid template path format: {template_path}. " + f"Expected format: 'WIDTHxHEIGHT/template.html' or 'templates/WIDTHxHEIGHT/template.html'" + ) + + # Parse size from directory name + if 'x' not in dir_name: + raise ValueError( + f"Invalid size format in path: {template_path}. " + f"Directory name should be 'WIDTHxHEIGHT' (e.g., '1080x1920')" + ) + + try: + width_str, height_str = dir_name.split('x') + width = int(width_str) + height = int(height_str) + + # Sanity check + if width < 100 or height < 100 or width > 10000 or height > 10000: + raise ValueError(f"Invalid size dimensions: {width}x{height}") + + return (width, height) + except ValueError as e: + raise ValueError( + f"Failed to parse size from path: {template_path}. " + f"Expected format: 'WIDTHxHEIGHT/template.html' (e.g., '1080x1920/default.html'). " + f"Error: {e}" + ) + + +def list_available_sizes() -> List[str]: + """ + List all available video sizes + + Returns: + List of size strings like ["1080x1920", "1920x1080", "1080x1080"] + + Examples: + >>> list_available_sizes() + ['1080x1920', '1920x1080', '1080x1080'] + """ + templates_dir = Path("templates") + + if not templates_dir.exists(): + return [] + + sizes = [] + for item in templates_dir.iterdir(): + if item.is_dir() and 'x' in item.name: + # Validate it's a proper size format + try: + width, height = item.name.split('x') + int(width) + int(height) + sizes.append(item.name) + except (ValueError, AttributeError): + # Skip invalid directories + continue + + return sorted(sizes) + + +def list_templates_for_size(size: str) -> List[str]: + """ + List all templates available for a given size + + Args: + size: Size string like "1080x1920" + + Returns: + List of template filenames (without path) like ["default.html", "modern.html"] + + Examples: + >>> list_templates_for_size("1080x1920") + ['cartoon.html', 'default.html', 'elegant.html', 'modern.html', ...] + """ + size_dir = Path("templates") / size + + if not size_dir.exists() or not size_dir.is_dir(): + return [] + + templates = [] + for item in size_dir.iterdir(): + if item.is_file() and item.suffix == '.html': + templates.append(item.name) + + return sorted(templates) + + +def get_template_full_path(size: str, template_name: str) -> str: + """ + Get full template path from size and template name + + Args: + size: Size string like "1080x1920" + template_name: Template filename like "default.html" + + Returns: + Full path like "templates/1080x1920/default.html" + + Raises: + FileNotFoundError: If template file doesn't exist + + Examples: + >>> get_template_full_path("1080x1920", "default.html") + 'templates/1080x1920/default.html' + """ + template_path = Path("templates") / size / template_name + + if not template_path.exists(): + available_templates = list_templates_for_size(size) + raise FileNotFoundError( + f"Template not found: {template_path}\n" + f"Available templates for size {size}: {available_templates}" + ) + + return str(template_path) + + +def resolve_template_path(template_input: Optional[str]) -> str: + """ + Resolve template input to full path with validation + + Args: + template_input: Can be: + - None: Use default "1080x1920/default.html" + - "template.html": Use default size + this template + - "1080x1920/template.html": Full relative path + - "templates/1080x1920/template.html": Absolute-ish path + + Returns: + Resolved full path like "templates/1080x1920/default.html" + + Raises: + FileNotFoundError: If template doesn't exist + + Examples: + >>> resolve_template_path(None) + 'templates/1080x1920/default.html' + >>> resolve_template_path("modern.html") + 'templates/1080x1920/modern.html' + >>> resolve_template_path("1920x1080/default.html") + 'templates/1920x1080/default.html' + """ + # Default case + if template_input is None: + template_input = "1080x1920/default.html" + + # If already starts with "templates/", use as-is + if template_input.startswith("templates/"): + template_path = Path(template_input) + # If contains size directory (e.g., "1080x1920/default.html") + elif '/' in template_input and 'x' in template_input.split('/')[0]: + template_path = Path("templates") / template_input + # Just template name (e.g., "default.html") + else: + # Use default size + template_path = Path("templates") / "1080x1920" / template_input + + # Validate existence + if not template_path.exists(): + available_sizes = list_available_sizes() + raise FileNotFoundError( + f"Template not found: {template_path}\n" + f"Available sizes: {available_sizes}\n" + f"Hint: Use format 'SIZExSIZE/template.html' (e.g., '1080x1920/default.html')" + ) + + return str(template_path) + diff --git a/templates/cartoon.html b/templates/1080x1920/cartoon.html similarity index 100% rename from templates/cartoon.html rename to templates/1080x1920/cartoon.html diff --git a/templates/default.html b/templates/1080x1920/default.html similarity index 100% rename from templates/default.html rename to templates/1080x1920/default.html diff --git a/templates/elegant.html b/templates/1080x1920/elegant.html similarity index 100% rename from templates/elegant.html rename to templates/1080x1920/elegant.html diff --git a/templates/modern.html b/templates/1080x1920/modern.html similarity index 100% rename from templates/modern.html rename to templates/1080x1920/modern.html diff --git a/templates/modernize.html b/templates/1080x1920/modernize.html similarity index 100% rename from templates/modernize.html rename to templates/1080x1920/modernize.html diff --git a/templates/neon.html b/templates/1080x1920/neon.html similarity index 100% rename from templates/neon.html rename to templates/1080x1920/neon.html diff --git a/templates/purple.html b/templates/1080x1920/purple.html similarity index 100% rename from templates/purple.html rename to templates/1080x1920/purple.html diff --git a/templates/tcm.html b/templates/1080x1920/tcm.html similarity index 100% rename from templates/tcm.html rename to templates/1080x1920/tcm.html diff --git a/web/app.py b/web/app.py index d0c0061..acf5eaf 100644 --- a/web/app.py +++ b/web/app.py @@ -666,24 +666,49 @@ def main(): st.markdown(f"**{tr('help.how')}**") st.markdown(tr("template.how")) - # Dynamically scan templates folder for HTML files - templates_folder = Path("templates") - template_files = [] - if templates_folder.exists(): - template_files = sorted([f.name for f in templates_folder.glob("*.html")]) + # Import template utilities + from pixelle_video.utils.template_util import list_available_sizes, list_templates_for_size + + # Step 1: Select video size + VIDEO_SIZE_OPTIONS = { + "📱 竖屏视频 (1080×1920)": "1080x1920", + "🖥 横屏视频 (1920×1080)": "1920x1080", + "⬜ 方形视频 (1080×1080)": "1080x1080", + } + + # Filter available sizes (only show sizes that exist) + available_sizes = list_available_sizes() + available_size_options = {k: v for k, v in VIDEO_SIZE_OPTIONS.items() if v in available_sizes} + + if not available_size_options: + st.error("No template sizes found. Please ensure templates are in correct directory structure.") + st.stop() + + selected_size_label = st.selectbox( + tr("template.video_size"), + list(available_size_options.keys()), + label_visibility="collapsed" + ) + selected_size = available_size_options[selected_size_label] + + # Step 2: Select template for the chosen size + template_files = list_templates_for_size(selected_size) # Default to default.html if exists, otherwise first option default_template_index = 0 if "default.html" in template_files: default_template_index = template_files.index("default.html") - frame_template = st.selectbox( - "Template", + template_name = st.selectbox( + tr("template.style"), template_files if template_files else ["default.html"], index=default_template_index, label_visibility="collapsed" ) + # Combine size and template name to get full path + frame_template = f"{selected_size}/{template_name}" + # Template preview expander with st.expander(tr("template.preview_title"), expanded=False): col1, col2 = st.columns(2) @@ -709,26 +734,10 @@ def main(): key="preview_text" ) - # Size settings in a compact row - col3, col4 = st.columns(2) - with col3: - preview_width = st.number_input( - tr("template.preview_param_width"), - value=1080, - min_value=100, - max_value=4096, - step=10, - key="preview_width" - ) - with col4: - preview_height = st.number_input( - tr("template.preview_param_height"), - value=1920, - min_value=100, - max_value=4096, - step=10, - key="preview_height" - ) + # Info: Size is auto-determined from template + from pixelle_video.utils.template_util import parse_template_size + template_width, template_height = parse_template_size(f"templates/{frame_template}") + st.info(f"📐 {tr('template.size_info')}: {template_width} × {template_height}") # Preview button if st.button(tr("template.preview_button"), key="btn_preview_template", use_container_width=True): @@ -736,17 +745,15 @@ def main(): try: from pixelle_video.services.frame_html import HTMLFrameGenerator - # Use the currently selected template + # Use the currently selected template (size is auto-parsed) template_path = f"templates/{frame_template}" generator = HTMLFrameGenerator(template_path) - # Generate preview + # Generate preview (size is auto-determined from template) preview_path = run_async(generator.generate_frame( title=preview_title, text=preview_text, - image=preview_image, - width=preview_width, - height=preview_height + image=preview_image )) # Display preview @@ -857,11 +864,17 @@ def main(): # Video information (compact display) file_size_mb = result.file_size / (1024 * 1024) + + # Parse video size from template path + from pixelle_video.utils.template_util import parse_template_size, resolve_template_path + template_path = resolve_template_path(result.storyboard.config.frame_template) + video_width, video_height = parse_template_size(template_path) + info_text = ( f"⏱️ {result.duration:.1f}s " f"📦 {file_size_mb:.2f}MB " f"🎬 {len(result.storyboard.frames)}{tr('info.scenes_unit')} " - f"📐 {result.storyboard.config.video_width}x{result.storyboard.config.video_height}" + f"📐 {video_width}x{video_height}" ) st.caption(info_text)