diff --git a/pixelle_video/models/storyboard.py b/pixelle_video/models/storyboard.py index 402c079..8f541ad 100644 --- a/pixelle_video/models/storyboard.py +++ b/pixelle_video/models/storyboard.py @@ -4,7 +4,7 @@ Storyboard data models for video generation from dataclasses import dataclass, field from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Dict, Any @dataclass @@ -36,6 +36,7 @@ class StoryboardConfig: # Frame template (includes size information in path) frame_template: str = "1080x1920/default.html" # Template path with size (e.g., "1080x1920/default.html") + template_params: Optional[Dict[str, Any]] = None # Custom template parameters (e.g., {"accent_color": "#ff0000"}) @dataclass diff --git a/pixelle_video/pipelines/standard.py b/pixelle_video/pipelines/standard.py index 407e8f3..ca86944 100644 --- a/pixelle_video/pipelines/standard.py +++ b/pixelle_video/pipelines/standard.py @@ -7,7 +7,7 @@ This is the default pipeline for general-purpose video generation. from datetime import datetime from pathlib import Path -from typing import Optional, Callable, Literal +from typing import Optional, Callable, Literal, Dict, Any from loguru import logger @@ -84,6 +84,7 @@ class StandardPipeline(BasePipeline): # === Frame Template (determines video size) === frame_template: Optional[str] = None, + template_params: Optional[Dict[str, Any]] = None, # Custom template parameters # === Image Style === prompt_prefix: Optional[str] = None, @@ -206,7 +207,8 @@ class StandardPipeline(BasePipeline): image_width=image_width, image_height=image_height, image_workflow=image_workflow, - frame_template=frame_template or "1080x1920/default.html" + frame_template=frame_template or "1080x1920/default.html", + template_params=template_params ) # Create storyboard diff --git a/pixelle_video/services/frame_html.py b/pixelle_video/services/frame_html.py index 567bfa8..6b7883c 100644 --- a/pixelle_video/services/frame_html.py +++ b/pixelle_video/services/frame_html.py @@ -12,6 +12,7 @@ Linux Environment Requirements: """ import os +import re import uuid from typing import Dict, Any, Optional from pathlib import Path @@ -103,6 +104,156 @@ class HTMLFrameGenerator: logger.debug(f"Template loaded: {len(content)} chars") return content + 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 @@ -248,11 +399,8 @@ class HTMLFrameGenerator: if ext: context.update(ext) - # Replace variables in HTML - html = self.template - for key, value in context.items(): - placeholder = f"{{{{{key}}}}}" - html = html.replace(placeholder, str(value) if value is not None else "") + # 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: diff --git a/pixelle_video/services/frame_processor.py b/pixelle_video/services/frame_processor.py index c10127b..3472d4b 100644 --- a/pixelle_video/services/frame_processor.py +++ b/pixelle_video/services/frame_processor.py @@ -213,6 +213,10 @@ class FrameProcessor: ext["content_subtitle"] = content_metadata.subtitle or "" ext["content_genre"] = content_metadata.genre or "" + # Add custom template parameters + if config.template_params: + ext.update(config.template_params) + # Generate frame using HTML (size is auto-parsed from template path) generator = HTMLFrameGenerator(template_path) composed_path = await generator.generate_frame( diff --git a/templates/1080x1920/modern.html b/templates/1080x1920/modern.html index 13d590e..1d33e33 100644 --- a/templates/1080x1920/modern.html +++ b/templates/1080x1920/modern.html @@ -12,7 +12,7 @@ margin: 0; padding: 0; width: 1080px; - background: #764ba2; + background: {{accent_color:color=#764ba2}}; font-family: 'PingFang SC', 'Source Han Sans', 'Microsoft YaHei', sans-serif; position: relative; overflow: hidden; @@ -174,7 +174,7 @@ .video-title-dot:nth-child(3) { background: #FFE66D; } .video-title { - font-size: 72px; + font-size: {{title_font_size:number=72}}px; font-weight: bold; color: white; line-height: 1.3; @@ -558,7 +558,7 @@
Open Source Omnimodal AI Creative Agent
- +
diff --git a/web/app.py b/web/app.py index f43229c..2fa8430 100644 --- a/web/app.py +++ b/web/app.py @@ -767,6 +767,86 @@ def main(): video_width, video_height = parse_template_size(frame_template) st.caption(tr("template.video_size_info", width=video_width, height=video_height)) + # Custom template parameters (for video generation) + from pixelle_video.services.frame_html import HTMLFrameGenerator + template_path_for_params = f"templates/{frame_template}" + generator_for_params = HTMLFrameGenerator(template_path_for_params) + custom_params_for_video = generator_for_params.parse_template_parameters() + + custom_values_for_video = {} + if custom_params_for_video: + st.markdown("📝 " + tr("template.custom_parameters")) + + # Render custom parameter inputs in 2 columns + video_custom_col1, video_custom_col2 = st.columns(2) + + param_items = list(custom_params_for_video.items()) + mid_point = (len(param_items) + 1) // 2 + + # Left column parameters + with video_custom_col1: + for param_name, config in param_items[:mid_point]: + param_type = config['type'] + default = config['default'] + label = config['label'] + + if param_type == 'text': + custom_values_for_video[param_name] = st.text_input( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'number': + custom_values_for_video[param_name] = st.number_input( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'color': + custom_values_for_video[param_name] = st.color_picker( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'bool': + custom_values_for_video[param_name] = st.checkbox( + label, + value=default, + key=f"video_custom_{param_name}" + ) + + # Right column parameters + with video_custom_col2: + for param_name, config in param_items[mid_point:]: + param_type = config['type'] + default = config['default'] + label = config['label'] + + if param_type == 'text': + custom_values_for_video[param_name] = st.text_input( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'number': + custom_values_for_video[param_name] = st.number_input( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'color': + custom_values_for_video[param_name] = st.color_picker( + label, + value=default, + key=f"video_custom_{param_name}" + ) + elif param_type == 'bool': + custom_values_for_video[param_name] = st.checkbox( + label, + value=default, + key=f"video_custom_{param_name}" + ) + # Template preview expander with st.expander(tr("template.preview_title"), expanded=False): col1, col2 = st.columns(2) @@ -797,21 +877,96 @@ def main(): template_width, template_height = parse_template_size(f"templates/{frame_template}") st.info(f"📐 {tr('template.size_info')}: {template_width} × {template_height}") + # Parse and render custom parameters + from pixelle_video.services.frame_html import HTMLFrameGenerator + template_path = f"templates/{frame_template}" + generator = HTMLFrameGenerator(template_path) + custom_params = generator.parse_template_parameters() + + preview_custom_values = {} + if custom_params: + st.markdown("📝 " + tr("template.custom_parameters")) + + # Render custom parameter inputs in 2 columns + custom_col1, custom_col2 = st.columns(2) + + param_items = list(custom_params.items()) + mid_point = (len(param_items) + 1) // 2 + + # Left column parameters + with custom_col1: + for param_name, config in param_items[:mid_point]: + param_type = config['type'] + default = config['default'] + label = config['label'] + + if param_type == 'text': + preview_custom_values[param_name] = st.text_input( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'number': + preview_custom_values[param_name] = st.number_input( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'color': + preview_custom_values[param_name] = st.color_picker( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'bool': + preview_custom_values[param_name] = st.checkbox( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + + # Right column parameters + with custom_col2: + for param_name, config in param_items[mid_point:]: + param_type = config['type'] + default = config['default'] + label = config['label'] + + if param_type == 'text': + preview_custom_values[param_name] = st.text_input( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'number': + preview_custom_values[param_name] = st.number_input( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'color': + preview_custom_values[param_name] = st.color_picker( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + elif param_type == 'bool': + preview_custom_values[param_name] = st.checkbox( + label, + value=default, + key=f"preview_custom_{param_name}" + ) + # Preview button if st.button(tr("template.preview_button"), key="btn_preview_template", use_container_width=True): with st.spinner(tr("template.preview_generating")): try: - from pixelle_video.services.frame_html import HTMLFrameGenerator - - # Use the currently selected template (size is auto-parsed) - template_path = f"templates/{frame_template}" - generator = HTMLFrameGenerator(template_path) - # Generate preview (size is auto-determined from template) preview_path = run_async(generator.generate_frame( title=preview_title, text=preview_text, - image=preview_image + image=preview_image, + ext=preview_custom_values if preview_custom_values else None )) # Display preview @@ -908,6 +1063,10 @@ def main(): "progress_callback": update_progress, } + # Add custom template parameters if any + if custom_values_for_video: + video_params["template_params"] = custom_values_for_video + # Add ref_audio if uploaded if ref_audio_path: video_params["ref_audio"] = str(ref_audio_path) diff --git a/web/i18n/locales/en_US.json b/web/i18n/locales/en_US.json index 2757625..239f0c9 100644 --- a/web/i18n/locales/en_US.json +++ b/web/i18n/locales/en_US.json @@ -100,6 +100,7 @@ "template.preview_failed": "❌ Preview failed: {error}", "template.preview_image_help": "Supports local path or URL", "template.preview_caption": "Template Preview: {template}", + "template.custom_parameters": "Custom Parameters", "video.title": "🎬 Video Settings", "video.frames": "Scenes", diff --git a/web/i18n/locales/zh_CN.json b/web/i18n/locales/zh_CN.json index ea35f82..b0a9613 100644 --- a/web/i18n/locales/zh_CN.json +++ b/web/i18n/locales/zh_CN.json @@ -100,6 +100,7 @@ "template.preview_failed": "❌ 预览失败:{error}", "template.preview_image_help": "支持本地路径或 URL", "template.preview_caption": "模板预览:{template}", + "template.custom_parameters": "自定义参数", "video.title": "🎬 视频设置", "video.frames": "分镜数",