| 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 | Style configuration components for web UI (middle column) |
| 15 | """ |
| 16 | |
| 17 | import os |
| 18 | import base64 |
| 19 | from pathlib import Path |
| 20 | |
| 21 | import streamlit as st |
| 22 | from loguru import logger |
| 23 | |
| 24 | from web.i18n import tr, get_language |
| 25 | from web.utils.async_helpers import run_async |
| 26 | from web.utils.streamlit_helpers import check_and_warn_selfhost_workflow |
| 27 | from web.pipelines.api_workflows import ( |
| 28 | list_api_media_workflows, |
| 29 | list_local_media_workflows, |
| 30 | render_api_video_controls, |
| 31 | workflow_select_help, |
| 32 | workflow_source_help, |
| 33 | workflow_source_label, |
| 34 | ) |
| 35 | from pixelle_video.config import config_manager |
| 36 | |
| 37 | |
| 38 | def is_api_workflow(workflow_key: str | None) -> bool: |
| 39 | """Return True for direct provider workflow keys such as api/dashscope/xxx.""" |
| 40 | return bool(workflow_key and workflow_key.startswith("api/")) |
| 41 | |
| 42 | |
| 43 | def render_style_config(pixelle_video): |
| 44 | """Render style configuration section (middle column)""" |
| 45 | # TTS Section (moved from left column) |
| 46 | # ==================================================================== |
| 47 | with st.container(border=True): |
| 48 | st.markdown(f"**{tr('section.tts')}**") |
| 49 | |
| 50 | with st.expander(tr("help.feature_description"), expanded=False): |
| 51 | st.markdown(f"**{tr('help.what')}**") |
| 52 | st.markdown(tr("tts.what")) |
| 53 | st.markdown(f"**{tr('help.how')}**") |
| 54 | st.markdown(tr("tts.how")) |
| 55 | |
| 56 | # Get TTS config |
| 57 | comfyui_config = config_manager.get_comfyui_config() |
| 58 | tts_config = comfyui_config["tts"] |
| 59 | |
| 60 | # Inference mode selection |
| 61 | tts_mode = st.radio( |
| 62 | tr("tts.inference_mode"), |
| 63 | ["local", "comfyui"], |
| 64 | horizontal=True, |
| 65 | format_func=lambda x: tr(f"tts.mode.{x}"), |
| 66 | index=0 if tts_config.get("inference_mode", "local") == "local" else 1, |
| 67 | key="tts_inference_mode" |
| 68 | ) |
| 69 | |
| 70 | # Show hint based on mode |
| 71 | if tts_mode == "local": |
| 72 | st.caption(tr("tts.mode.local_hint")) |
| 73 | else: |
| 74 | st.caption(tr("tts.mode.comfyui_hint")) |
| 75 | |
| 76 | # ================================================================ |
| 77 | # Local Mode UI |
| 78 | # ================================================================ |
| 79 | if tts_mode == "local": |
| 80 | # Import voice configuration |
| 81 | from pixelle_video.tts_voices import EDGE_TTS_VOICES, get_voice_display_name |
| 82 | |
| 83 | # Get saved voice from config |
| 84 | local_config = tts_config.get("local", {}) |
| 85 | saved_voice = local_config.get("voice", "zh-CN-YunjianNeural") |
| 86 | saved_speed = local_config.get("speed", 1.2) |
| 87 | |
| 88 | # Build voice options with i18n |
| 89 | voice_options = [] |
| 90 | voice_ids = [] |
| 91 | default_voice_index = 0 |
| 92 | |
| 93 | for idx, voice_config in enumerate(EDGE_TTS_VOICES): |
| 94 | voice_id = voice_config["id"] |
| 95 | display_name = get_voice_display_name(voice_id, tr, get_language()) |
| 96 | voice_options.append(display_name) |
| 97 | voice_ids.append(voice_id) |
| 98 | |
| 99 | # Set default index if matches saved voice |
| 100 | if voice_id == saved_voice: |
| 101 | default_voice_index = idx |
| 102 | |
| 103 | # Two-column layout: Voice | Speed |
| 104 | voice_col, speed_col = st.columns([1, 1]) |
| 105 | |
| 106 | with voice_col: |
| 107 | # Voice selector |
| 108 | selected_voice_display = st.selectbox( |
| 109 | tr("tts.voice_selector"), |
| 110 | voice_options, |
| 111 | index=default_voice_index, |
| 112 | key="tts_local_voice" |
| 113 | ) |
| 114 | |
| 115 | # Get actual voice ID |
| 116 | selected_voice_index = voice_options.index(selected_voice_display) |
| 117 | selected_voice = voice_ids[selected_voice_index] |
| 118 | |
| 119 | with speed_col: |
| 120 | # Speed slider |
| 121 | tts_speed = st.slider( |
| 122 | tr("tts.speed"), |
| 123 | min_value=0.5, |
| 124 | max_value=2.0, |
| 125 | value=saved_speed, |
| 126 | step=0.1, |
| 127 | format="%.1fx", |
| 128 | key="tts_local_speed" |
| 129 | ) |
| 130 | st.caption(tr("tts.speed_label", speed=f"{tts_speed:.1f}")) |
| 131 | |
| 132 | # Variables for video generation |
| 133 | tts_workflow_key = None |
| 134 | ref_audio_path = None |
| 135 | |
| 136 | # ================================================================ |
| 137 | # ComfyUI Mode UI |
| 138 | # ================================================================ |
| 139 | else: # comfyui mode |
| 140 | # Get available TTS workflows |
| 141 | tts_workflows = pixelle_video.tts.list_workflows() |
| 142 | |
| 143 | # Build options for selectbox |
| 144 | tts_workflow_options = [wf["display_name"] for wf in tts_workflows] |
| 145 | tts_workflow_keys = [wf["key"] for wf in tts_workflows] |
| 146 | |
| 147 | # Default to saved workflow if exists |
| 148 | default_tts_index = 0 |
| 149 | saved_tts_workflow = tts_config.get("comfyui", {}).get("default_workflow") |
| 150 | if saved_tts_workflow and saved_tts_workflow in tts_workflow_keys: |
| 151 | default_tts_index = tts_workflow_keys.index(saved_tts_workflow) |
| 152 | |
| 153 | tts_workflow_display = st.selectbox( |
| 154 | "TTS Workflow", |
| 155 | tts_workflow_options if tts_workflow_options else ["No TTS workflows found"], |
| 156 | index=default_tts_index, |
| 157 | label_visibility="collapsed", |
| 158 | key="tts_workflow_select" |
| 159 | ) |
| 160 | |
| 161 | # Get the actual workflow key |
| 162 | if tts_workflow_options: |
| 163 | tts_selected_index = tts_workflow_options.index(tts_workflow_display) |
| 164 | tts_workflow_key = tts_workflow_keys[tts_selected_index] |
| 165 | else: |
| 166 | tts_workflow_key = "selfhost/tts_edge.json" # fallback |
| 167 | |
| 168 | # Check and warn for selfhost TTS workflow (auto popup if not confirmed) |
| 169 | check_and_warn_selfhost_workflow(tts_workflow_key) |
| 170 | |
| 171 | # Reference audio upload (optional, for voice cloning) |
| 172 | ref_audio_file = st.file_uploader( |
| 173 | tr("tts.ref_audio"), |
| 174 | type=["mp3", "wav", "flac", "m4a", "aac", "ogg"], |
| 175 | help=tr("tts.ref_audio_help"), |
| 176 | key="ref_audio_upload" |
| 177 | ) |
| 178 | |
| 179 | # Save uploaded ref_audio to temp file if provided |
| 180 | ref_audio_path = None |
| 181 | if ref_audio_file is not None: |
| 182 | # Audio preview player (directly play uploaded file) |
| 183 | st.audio(ref_audio_file) |
| 184 | |
| 185 | # Save to temp directory |
| 186 | temp_dir = Path("temp") |
| 187 | temp_dir.mkdir(exist_ok=True) |
| 188 | ref_audio_path = temp_dir / f"ref_audio_{ref_audio_file.name}" |
| 189 | with open(ref_audio_path, "wb") as f: |
| 190 | f.write(ref_audio_file.getbuffer()) |
| 191 | |
| 192 | # Variables for video generation |
| 193 | selected_voice = None |
| 194 | tts_speed = None |
| 195 | |
| 196 | # ================================================================ |
| 197 | # TTS Preview (works for both modes) |
| 198 | # ================================================================ |
| 199 | with st.expander(tr("tts.preview_title"), expanded=False): |
| 200 | # Preview text input |
| 201 | preview_text = st.text_input( |
| 202 | tr("tts.preview_text"), |
| 203 | value="大家好,这是一段测试语音。", |
| 204 | placeholder=tr("tts.preview_text_placeholder"), |
| 205 | key="tts_preview_text" |
| 206 | ) |
| 207 | |
| 208 | # Preview button |
| 209 | if st.button(tr("tts.preview_button"), key="preview_tts", use_container_width=True): |
| 210 | with st.spinner(tr("tts.previewing")): |
| 211 | try: |
| 212 | # Build TTS params based on mode |
| 213 | tts_params = { |
| 214 | "text": preview_text, |
| 215 | "inference_mode": tts_mode |
| 216 | } |
| 217 | |
| 218 | if tts_mode == "local": |
| 219 | tts_params["voice"] = selected_voice |
| 220 | tts_params["speed"] = tts_speed |
| 221 | else: # comfyui |
| 222 | tts_params["workflow"] = tts_workflow_key |
| 223 | if ref_audio_path: |
| 224 | tts_params["ref_audio"] = str(ref_audio_path) |
| 225 | |
| 226 | audio_path = run_async(pixelle_video.tts(**tts_params)) |
| 227 | |
| 228 | # Play the audio |
| 229 | if audio_path: |
| 230 | st.success(tr("tts.preview_success")) |
| 231 | if os.path.exists(audio_path): |
| 232 | st.audio(audio_path, format="audio/mp3") |
| 233 | elif audio_path.startswith('http'): |
| 234 | st.audio(audio_path) |
| 235 | else: |
| 236 | st.error("Failed to generate preview audio") |
| 237 | |
| 238 | # Show file path |
| 239 | st.caption(f"📁 {audio_path}") |
| 240 | else: |
| 241 | st.error("Failed to generate preview audio") |
| 242 | except Exception as e: |
| 243 | st.error(tr("tts.preview_failed", error=str(e))) |
| 244 | logger.exception(e) |
| 245 | |
| 246 | # ==================================================================== |
| 247 | # Storyboard Template Section |
| 248 | # ==================================================================== |
| 249 | |
| 250 | def get_template_preview_path(template_path: str, language: str = "zh_CN") -> str: |
| 251 | """ |
| 252 | Get the preview image path for a template based on language. |
| 253 | |
| 254 | Args: |
| 255 | template_path: Template path like "1080x1920/image_default.html" |
| 256 | language: Language code, either "zh_CN" or "en" |
| 257 | |
| 258 | Returns: |
| 259 | Path to preview image in docs/images/ |
| 260 | """ |
| 261 | # Extract size and template name from path |
| 262 | # e.g., "1080x1920/image_default.html" -> size="1080x1920", name="image_default" |
| 263 | path_parts = template_path.split('/') |
| 264 | if len(path_parts) >= 2: |
| 265 | size = path_parts[0] # e.g., "1080x1920" |
| 266 | template_file = path_parts[1] # e.g., "image_default.html" |
| 267 | template_name = template_file.replace('.html', '') # e.g., "image_default" |
| 268 | |
| 269 | # Build preview image path |
| 270 | # Format: docs/images/{size}/{template_name}.jpg or {template_name}_en.jpg |
| 271 | # Chinese uses Chinese preview, all other languages use English preview for better i18n |
| 272 | suffix = "" if language == "zh_CN" else "_en" |
| 273 | |
| 274 | # Try different image extensions |
| 275 | for ext in ['.jpg', '.png']: |
| 276 | preview_path = f"docs/images/{size}/{template_name}{suffix}{ext}" |
| 277 | if os.path.exists(preview_path): |
| 278 | return preview_path |
| 279 | |
| 280 | # Fallback: try without language suffix (for templates with only one version) |
| 281 | for ext in ['.jpg', '.png']: |
| 282 | preview_path = f"docs/images/{size}/{template_name}{ext}" |
| 283 | if os.path.exists(preview_path): |
| 284 | return preview_path |
| 285 | |
| 286 | # If no preview found, return empty string |
| 287 | return "" |
| 288 | |
| 289 | with st.container(border=True): |
| 290 | st.markdown(f"**{tr('section.template')}**") |
| 291 | |
| 292 | with st.expander(tr("help.feature_description"), expanded=False): |
| 293 | st.markdown(f"**{tr('help.what')}**") |
| 294 | st.markdown(tr("template.what")) |
| 295 | st.markdown(f"**{tr('help.how')}**") |
| 296 | st.markdown(tr("template.how")) |
| 297 | |
| 298 | # Template preview link (based on language) |
| 299 | current_lang = get_language() |
| 300 | |
| 301 | # Import template utilities |
| 302 | from pixelle_video.utils.template_util import get_templates_grouped_by_size_and_type, get_template_type |
| 303 | |
| 304 | # Template type selector |
| 305 | st.markdown(f"**{tr('template.type_selector')}**") |
| 306 | |
| 307 | template_type_options = { |
| 308 | 'static': tr('template.type.static'), |
| 309 | 'image': tr('template.type.image'), |
| 310 | 'video': tr('template.type.video'), |
| 311 | } |
| 312 | |
| 313 | # Radio buttons in horizontal layout |
| 314 | selected_template_type = st.radio( |
| 315 | tr('template.type_selector'), |
| 316 | options=list(template_type_options.keys()), |
| 317 | format_func=lambda x: template_type_options[x], |
| 318 | index=1, # Default to 'image' |
| 319 | key="template_type_selector", |
| 320 | label_visibility="collapsed", |
| 321 | horizontal=True |
| 322 | ) |
| 323 | |
| 324 | # Display hint based on selected type (below radio buttons) |
| 325 | if selected_template_type == 'static': |
| 326 | st.info(tr('template.type.static_hint')) |
| 327 | elif selected_template_type == 'image': |
| 328 | st.info(tr('template.type.image_hint')) |
| 329 | elif selected_template_type == 'video': |
| 330 | st.info(tr('template.type.video_hint')) |
| 331 | |
| 332 | # Get templates grouped by size, filtered by selected type |
| 333 | grouped_templates = get_templates_grouped_by_size_and_type(selected_template_type) |
| 334 | |
| 335 | if not grouped_templates: |
| 336 | st.warning(f"No {template_type_options[selected_template_type]} templates found. Please select a different type or add templates.") |
| 337 | st.stop() |
| 338 | |
| 339 | # Build orientation i18n mapping |
| 340 | ORIENTATION_I18N = { |
| 341 | 'portrait': tr('orientation.portrait'), |
| 342 | 'landscape': tr('orientation.landscape'), |
| 343 | 'square': tr('orientation.square') |
| 344 | } |
| 345 | |
| 346 | # Get default template from config |
| 347 | template_config = pixelle_video.config.get("template", {}) |
| 348 | config_default_template = template_config.get("default_template", "1080x1920/image_default.html") |
| 349 | |
| 350 | # Backward compatibility |
| 351 | if config_default_template == "1080x1920/default.html": |
| 352 | config_default_template = "1080x1920/image_default.html" |
| 353 | |
| 354 | # Determine type-specific default template |
| 355 | type_default_templates = { |
| 356 | 'static': '1080x1920/static_default.html', |
| 357 | 'image': '1080x1920/image_default.html', |
| 358 | 'video': '1080x1920/video_default.html', |
| 359 | } |
| 360 | type_specific_default = type_default_templates.get(selected_template_type, config_default_template) |
| 361 | |
| 362 | # Initialize selected template in session state if not exists |
| 363 | if 'selected_template' not in st.session_state: |
| 364 | st.session_state['selected_template'] = type_specific_default |
| 365 | |
| 366 | # Track last selected template type to detect type changes |
| 367 | last_template_type = st.session_state.get('last_template_type', None) |
| 368 | if last_template_type != selected_template_type: |
| 369 | # Template type changed, reset to type-specific default |
| 370 | st.session_state['selected_template'] = type_specific_default |
| 371 | st.session_state['last_template_type'] = selected_template_type |
| 372 | |
| 373 | # Collect size groups and prepare tabs |
| 374 | size_groups = [] |
| 375 | size_labels = [] |
| 376 | |
| 377 | for size, templates in grouped_templates.items(): |
| 378 | if not templates: |
| 379 | continue |
| 380 | |
| 381 | # Filter templates to only include those with proper naming convention |
| 382 | # Only show templates starting with static_, image_, or video_ |
| 383 | valid_templates = [] |
| 384 | for template in templates: |
| 385 | template_name = template.display_info.name |
| 386 | if template_name.startswith(('static_', 'image_', 'video_')): |
| 387 | valid_templates.append(template) |
| 388 | |
| 389 | # Skip if no valid templates after filtering |
| 390 | if not valid_templates: |
| 391 | continue |
| 392 | |
| 393 | # Separate templates into two groups: with preview and without preview |
| 394 | templates_with_preview = [] |
| 395 | templates_without_preview = [] |
| 396 | |
| 397 | for template in valid_templates: |
| 398 | preview_path = get_template_preview_path(template.template_path, current_lang) |
| 399 | if preview_path and os.path.exists(preview_path): |
| 400 | templates_with_preview.append(template) |
| 401 | else: |
| 402 | templates_without_preview.append(template) |
| 403 | |
| 404 | # Skip this group if no templates at all |
| 405 | if not templates_with_preview and not templates_without_preview: |
| 406 | continue |
| 407 | |
| 408 | # Combine: templates with preview first, then without preview |
| 409 | all_templates = templates_with_preview + templates_without_preview |
| 410 | |
| 411 | # Get orientation from first template in group |
| 412 | orientation = ORIENTATION_I18N.get( |
| 413 | all_templates[0].display_info.orientation, |
| 414 | all_templates[0].display_info.orientation |
| 415 | ) |
| 416 | width = all_templates[0].display_info.width |
| 417 | height = all_templates[0].display_info.height |
| 418 | |
| 419 | # Create tab label |
| 420 | tab_label = f"{orientation} {width}×{height}" |
| 421 | size_labels.append(tab_label) |
| 422 | size_groups.append(all_templates) |
| 423 | |
| 424 | # Create tabs for each size group (wrapped in expander) |
| 425 | with st.expander(tr("template.gallery_view"), expanded=True): |
| 426 | if size_groups: |
| 427 | tabs = st.tabs(size_labels) |
| 428 | |
| 429 | for tab, all_templates in zip(tabs, size_groups): |
| 430 | with tab: |
| 431 | # Create grid layout (5 columns) |
| 432 | num_cols = 5 |
| 433 | cols = st.columns(num_cols) |
| 434 | |
| 435 | for idx, template in enumerate(all_templates): |
| 436 | col_idx = idx % num_cols |
| 437 | with cols[col_idx]: |
| 438 | # Get preview image path |
| 439 | preview_path = get_template_preview_path(template.template_path, current_lang) |
| 440 | |
| 441 | # Display preview image or placeholder |
| 442 | if preview_path and os.path.exists(preview_path): |
| 443 | st.image(preview_path, use_container_width=True) |
| 444 | else: |
| 445 | # Placeholder for templates without preview (fixed height, compact layout) |
| 446 | st.markdown( |
| 447 | f""" |
| 448 | <div style=" |
| 449 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| 450 | height: 150px; |
| 451 | display: flex; |
| 452 | align-items: center; |
| 453 | justify-content: center; |
| 454 | text-align: center; |
| 455 | border-radius: 8px; |
| 456 | color: white; |
| 457 | margin-bottom: 15px; |
| 458 | padding: 10px; |
| 459 | "> |
| 460 | <div style=" |
| 461 | font-size: 14px; |
| 462 | opacity: 0.95; |
| 463 | overflow: hidden; |
| 464 | text-overflow: ellipsis; |
| 465 | display: -webkit-box; |
| 466 | -webkit-line-clamp: 5; |
| 467 | -webkit-box-orient: vertical; |
| 468 | word-break: break-all; |
| 469 | ">{template.display_info.name}</div> |
| 470 | </div> |
| 471 | """, |
| 472 | unsafe_allow_html=True |
| 473 | ) |
| 474 | |
| 475 | # Select button (unified label) |
| 476 | is_selected = (st.session_state['selected_template'] == template.template_path) |
| 477 | button_label = f"{tr('template.selected')}" if is_selected else tr('template.select_button') |
| 478 | button_type = "primary" if is_selected else "secondary" |
| 479 | |
| 480 | if st.button( |
| 481 | button_label, |
| 482 | key=f"template_{template.template_path}", |
| 483 | use_container_width=True, |
| 484 | type=button_type, |
| 485 | ): |
| 486 | st.session_state['selected_template'] = template.template_path |
| 487 | st.rerun() |
| 488 | else: |
| 489 | st.warning(tr("template.no_templates_with_preview")) |
| 490 | |
| 491 | # Display selected template name (inside expander, below tabs) |
| 492 | frame_template = st.session_state['selected_template'] |
| 493 | |
| 494 | # Find the selected template's display name |
| 495 | selected_template_name = None |
| 496 | for size, templates in grouped_templates.items(): |
| 497 | for template in templates: |
| 498 | if template.template_path == frame_template: |
| 499 | selected_template_name = template.display_info.name |
| 500 | break |
| 501 | if selected_template_name: |
| 502 | break |
| 503 | |
| 504 | if selected_template_name: |
| 505 | st.info(f"📋 {tr('template.selected_template')}: **{selected_template_name}**") |
| 506 | |
| 507 | |
| 508 | # Display video size from template |
| 509 | from pixelle_video.utils.template_util import parse_template_size |
| 510 | video_width, video_height = parse_template_size(frame_template) |
| 511 | st.caption(tr("template.video_size_info", width=video_width, height=video_height)) |
| 512 | |
| 513 | # Custom template parameters (for video generation) |
| 514 | from pixelle_video.services.frame_html import HTMLFrameGenerator |
| 515 | # Resolve template path to support both data/templates/ and templates/ |
| 516 | from pixelle_video.utils.template_util import resolve_template_path |
| 517 | template_path_for_params = resolve_template_path(frame_template) |
| 518 | generator_for_params = HTMLFrameGenerator(template_path_for_params) |
| 519 | custom_params_for_video = generator_for_params.parse_template_parameters() |
| 520 | |
| 521 | # Get media size from template (for image/video generation) |
| 522 | media_width, media_height = generator_for_params.get_media_size() |
| 523 | st.session_state['template_media_width'] = media_width |
| 524 | st.session_state['template_media_height'] = media_height |
| 525 | |
| 526 | # Detect template media type |
| 527 | from pixelle_video.utils.template_util import get_template_type |
| 528 | |
| 529 | template_name = Path(frame_template).name |
| 530 | template_media_type = get_template_type(template_name) |
| 531 | template_requires_media = (template_media_type in ["image", "video"]) |
| 532 | |
| 533 | # Store in session state for workflow filtering |
| 534 | st.session_state['template_media_type'] = template_media_type |
| 535 | st.session_state['template_requires_media'] = template_requires_media |
| 536 | |
| 537 | # Backward compatibility |
| 538 | st.session_state['template_requires_image'] = (template_media_type == "image") |
| 539 | |
| 540 | custom_values_for_video = {} |
| 541 | if custom_params_for_video: |
| 542 | st.markdown("📝 " + tr("template.custom_parameters")) |
| 543 | |
| 544 | # Render custom parameter inputs in 2 columns |
| 545 | video_custom_col1, video_custom_col2 = st.columns(2) |
| 546 | |
| 547 | param_items = list(custom_params_for_video.items()) |
| 548 | mid_point = (len(param_items) + 1) // 2 |
| 549 | |
| 550 | # Left column parameters |
| 551 | with video_custom_col1: |
| 552 | for param_name, config in param_items[:mid_point]: |
| 553 | param_type = config['type'] |
| 554 | default = config['default'] |
| 555 | label = config['label'] |
| 556 | |
| 557 | if param_type == 'text': |
| 558 | custom_values_for_video[param_name] = st.text_input( |
| 559 | label, |
| 560 | value=default, |
| 561 | key=f"video_custom_{param_name}" |
| 562 | ) |
| 563 | elif param_type == 'number': |
| 564 | custom_values_for_video[param_name] = st.number_input( |
| 565 | label, |
| 566 | value=default, |
| 567 | key=f"video_custom_{param_name}" |
| 568 | ) |
| 569 | elif param_type == 'color': |
| 570 | custom_values_for_video[param_name] = st.color_picker( |
| 571 | label, |
| 572 | value=default, |
| 573 | key=f"video_custom_{param_name}" |
| 574 | ) |
| 575 | elif param_type == 'bool': |
| 576 | custom_values_for_video[param_name] = st.checkbox( |
| 577 | label, |
| 578 | value=default, |
| 579 | key=f"video_custom_{param_name}" |
| 580 | ) |
| 581 | |
| 582 | # Right column parameters |
| 583 | with video_custom_col2: |
| 584 | for param_name, config in param_items[mid_point:]: |
| 585 | param_type = config['type'] |
| 586 | default = config['default'] |
| 587 | label = config['label'] |
| 588 | |
| 589 | if param_type == 'text': |
| 590 | custom_values_for_video[param_name] = st.text_input( |
| 591 | label, |
| 592 | value=default, |
| 593 | key=f"video_custom_{param_name}" |
| 594 | ) |
| 595 | elif param_type == 'number': |
| 596 | custom_values_for_video[param_name] = st.number_input( |
| 597 | label, |
| 598 | value=default, |
| 599 | key=f"video_custom_{param_name}" |
| 600 | ) |
| 601 | elif param_type == 'color': |
| 602 | custom_values_for_video[param_name] = st.color_picker( |
| 603 | label, |
| 604 | value=default, |
| 605 | key=f"video_custom_{param_name}" |
| 606 | ) |
| 607 | elif param_type == 'bool': |
| 608 | custom_values_for_video[param_name] = st.checkbox( |
| 609 | label, |
| 610 | value=default, |
| 611 | key=f"video_custom_{param_name}" |
| 612 | ) |
| 613 | |
| 614 | # Template preview expander |
| 615 | with st.expander(tr("template.preview_title"), expanded=False): |
| 616 | col1, col2 = st.columns(2) |
| 617 | |
| 618 | with col1: |
| 619 | preview_title = st.text_input( |
| 620 | tr("template.preview_param_title"), |
| 621 | value=tr("template.preview_default_title"), |
| 622 | key="preview_title" |
| 623 | ) |
| 624 | preview_image = st.text_input( |
| 625 | tr("template.preview_param_image"), |
| 626 | value="resources/example.png", |
| 627 | help=tr("template.preview_image_help"), |
| 628 | key="preview_image" |
| 629 | ) |
| 630 | |
| 631 | with col2: |
| 632 | preview_text = st.text_area( |
| 633 | tr("template.preview_param_text"), |
| 634 | value=tr("template.preview_default_text"), |
| 635 | height=100, |
| 636 | key="preview_text" |
| 637 | ) |
| 638 | |
| 639 | # Info: Size is auto-determined from template |
| 640 | from pixelle_video.utils.template_util import parse_template_size, resolve_template_path |
| 641 | template_width, template_height = parse_template_size(resolve_template_path(frame_template)) |
| 642 | st.info(f"📐 {tr('template.size_info')}: {template_width} × {template_height}") |
| 643 | |
| 644 | # Preview button |
| 645 | if st.button(tr("template.preview_button"), key="btn_preview_template", use_container_width=True): |
| 646 | with st.spinner(tr("template.preview_generating")): |
| 647 | try: |
| 648 | from pixelle_video.services.frame_html import HTMLFrameGenerator |
| 649 | |
| 650 | # Use the currently selected template (size is auto-parsed) |
| 651 | from pixelle_video.utils.template_util import resolve_template_path |
| 652 | template_path = resolve_template_path(frame_template) |
| 653 | generator = HTMLFrameGenerator(template_path) |
| 654 | |
| 655 | # Build ext dict with auto-injected parameters (same as FrameProcessor) |
| 656 | ext = { |
| 657 | "index": 1, # Preview uses index 1 |
| 658 | } |
| 659 | |
| 660 | # Add custom parameters from user input |
| 661 | if custom_values_for_video: |
| 662 | ext.update(custom_values_for_video) |
| 663 | |
| 664 | # Generate preview |
| 665 | preview_path = run_async(generator.generate_frame( |
| 666 | title=preview_title, |
| 667 | text=preview_text, |
| 668 | image=preview_image, |
| 669 | ext=ext |
| 670 | )) |
| 671 | |
| 672 | # Display preview |
| 673 | if preview_path: |
| 674 | st.success(tr("template.preview_success")) |
| 675 | st.image( |
| 676 | preview_path, |
| 677 | caption=tr("template.preview_caption", template=frame_template), |
| 678 | ) |
| 679 | |
| 680 | # Show file path |
| 681 | st.caption(f"📁 {preview_path}") |
| 682 | else: |
| 683 | st.error("Failed to generate preview") |
| 684 | |
| 685 | except Exception as e: |
| 686 | st.error(tr("template.preview_failed", error=str(e))) |
| 687 | logger.exception(e) |
| 688 | |
| 689 | # ==================================================================== |
| 690 | # Media Generation Section (conditional based on template) |
| 691 | # ==================================================================== |
| 692 | # Check if current template requires media generation |
| 693 | template_media_type = st.session_state.get('template_media_type', 'image') |
| 694 | template_requires_media = st.session_state.get('template_requires_media', True) |
| 695 | |
| 696 | api_video_params = {} |
| 697 | |
| 698 | if template_requires_media: |
| 699 | comfyui_config = config_manager.get_comfyui_config() |
| 700 | media_width = st.session_state.get('template_media_width') |
| 701 | media_height = st.session_state.get('template_media_height') |
| 702 | media_config_key = "video" if template_media_type == "video" else "image" |
| 703 | saved_workflow = comfyui_config.get(media_config_key, {}).get("default_workflow") or "" |
| 704 | workflow_key = None |
| 705 | |
| 706 | with st.container(border=True): |
| 707 | section_title = tr('section.video') if template_media_type == "video" else tr('section.image') |
| 708 | st.markdown(f"**{section_title}**") |
| 709 | |
| 710 | # 1. ComfyUI Workflow selection |
| 711 | with st.expander(tr("help.feature_description"), expanded=False): |
| 712 | st.markdown(f"**{tr('help.what')}**") |
| 713 | if template_media_type == "video": |
| 714 | st.markdown(tr("style.video_workflow_what")) |
| 715 | else: |
| 716 | st.markdown(tr("style.workflow_what")) |
| 717 | st.markdown(f"**{tr('help.how')}**") |
| 718 | if template_media_type == "video": |
| 719 | st.markdown(tr("style.video_workflow_how")) |
| 720 | else: |
| 721 | st.markdown(tr("style.workflow_how")) |
| 722 | |
| 723 | source_options = ["runninghub", "selfhost", "api"] |
| 724 | default_source_index = 0 |
| 725 | for index, source in enumerate(source_options): |
| 726 | if saved_workflow.startswith(f"{source}/"): |
| 727 | default_source_index = index |
| 728 | break |
| 729 | source_key = "standard_video_workflow_source" if template_media_type == "video" else "standard_image_workflow_source" |
| 730 | workflow_source = st.radio( |
| 731 | "生成来源" if get_language() == "zh_CN" else "Generation source", |
| 732 | source_options, |
| 733 | index=default_source_index, |
| 734 | format_func=workflow_source_label, |
| 735 | horizontal=True, |
| 736 | key=source_key, |
| 737 | help=workflow_source_help("快速创作媒体生成" if get_language() == "zh_CN" else "Quick Create media generation"), |
| 738 | ) |
| 739 | |
| 740 | if workflow_source == "api": |
| 741 | if template_media_type == "video": |
| 742 | workflows = list_api_media_workflows( |
| 743 | pixelle_video, |
| 744 | "video", |
| 745 | required_adapter_abilities=["text_to_video"], |
| 746 | verified_only=True, |
| 747 | ) |
| 748 | else: |
| 749 | workflows = list_api_media_workflows(pixelle_video, "image") |
| 750 | elif template_media_type == "video": |
| 751 | workflows = list_local_media_workflows( |
| 752 | pixelle_video, |
| 753 | "video", |
| 754 | workflow_source, |
| 755 | key_contains="video_", |
| 756 | ) |
| 757 | else: |
| 758 | workflows = list_local_media_workflows(pixelle_video, "image", workflow_source) |
| 759 | |
| 760 | # Build options for selectbox |
| 761 | # Display: "image_flux.json - Runninghub" |
| 762 | # Value: "runninghub/image_flux.json" |
| 763 | workflow_options = [wf["display_name"] for wf in workflows] |
| 764 | workflow_keys = [wf["key"] for wf in workflows] |
| 765 | |
| 766 | # Default to first option (should be runninghub by sorting) |
| 767 | default_workflow_index = 0 |
| 768 | |
| 769 | # If user has a saved preference in config, try to match it |
| 770 | if saved_workflow and saved_workflow in workflow_keys: |
| 771 | default_workflow_index = workflow_keys.index(saved_workflow) |
| 772 | |
| 773 | workflow_display = st.selectbox( |
| 774 | "Workflow" if workflow_source != "api" else ("API 模型" if get_language() == "zh_CN" else "API model"), |
| 775 | workflow_options if workflow_options else ["No workflows found"], |
| 776 | index=default_workflow_index, |
| 777 | label_visibility="visible", |
| 778 | key=f"{source_key}_select", |
| 779 | help=workflow_select_help(), |
| 780 | ) |
| 781 | |
| 782 | # Get the actual workflow key (e.g., "runninghub/image_flux.json") |
| 783 | if workflow_options: |
| 784 | workflow_selected_index = workflow_options.index(workflow_display) |
| 785 | workflow_key = workflow_keys[workflow_selected_index] |
| 786 | workflow_info = workflows[workflow_selected_index] |
| 787 | else: |
| 788 | workflow_key = None |
| 789 | workflow_info = None |
| 790 | if workflow_source == "api" and template_media_type == "video": |
| 791 | st.warning( |
| 792 | "没有找到已验证的 API 文生视频模型,请先配置 DashScope/Seedance 等提供商,或切换到本地/RunningHub 工作流。" |
| 793 | if get_language() == "zh_CN" |
| 794 | else "No verified API text-to-video model found. Configure a provider or switch to local/RunningHub workflows." |
| 795 | ) |
| 796 | else: |
| 797 | st.warning( |
| 798 | "当前来源下没有可用工作流。" |
| 799 | if get_language() == "zh_CN" |
| 800 | else "No workflow is available for the selected source." |
| 801 | ) |
| 802 | |
| 803 | # Check and warn for selfhost media workflow (auto popup if not confirmed) |
| 804 | if workflow_key and not is_api_workflow(workflow_key): |
| 805 | check_and_warn_selfhost_workflow(workflow_key) |
| 806 | |
| 807 | # Display media size info (read-only) |
| 808 | if template_media_type == "video": |
| 809 | size_info_text = tr('style.video_size_info', width=media_width, height=media_height) |
| 810 | else: |
| 811 | size_info_text = tr('style.image_size_info', width=media_width, height=media_height) |
| 812 | st.info(f"📐 {size_info_text}") |
| 813 | |
| 814 | if template_media_type == "video" and media_width and media_height: |
| 815 | default_video_ratio = "1:1" if media_width == media_height else ("9:16" if media_height > media_width else "16:9") |
| 816 | else: |
| 817 | default_video_ratio = "9:16" |
| 818 | |
| 819 | if template_media_type == "video" and is_api_workflow(workflow_key): |
| 820 | api_video_params = render_api_video_controls( |
| 821 | workflow_info, |
| 822 | key_prefix="standard_video", |
| 823 | default_duration=5, |
| 824 | allow_audio_driven=False, |
| 825 | show_duration=False, |
| 826 | default_ratio=default_video_ratio, |
| 827 | ) |
| 828 | |
| 829 | # Prompt prefix input |
| 830 | # Get current prompt_prefix from config (based on media type) |
| 831 | current_prefix = comfyui_config.get(media_config_key, {}).get("prompt_prefix", "") |
| 832 | |
| 833 | # Prompt prefix input (temporary, not saved to config) |
| 834 | prompt_prefix = st.text_area( |
| 835 | tr('style.prompt_prefix'), |
| 836 | value=current_prefix, |
| 837 | placeholder=tr("style.prompt_prefix_placeholder"), |
| 838 | height=80, |
| 839 | label_visibility="visible", |
| 840 | help=tr("style.prompt_prefix_help") |
| 841 | ) |
| 842 | |
| 843 | # Media preview expander |
| 844 | preview_title = tr("style.video_preview_title") if template_media_type == "video" else tr("style.preview_title") |
| 845 | with st.expander(preview_title, expanded=False): |
| 846 | # Test prompt input |
| 847 | test_prompt_label = tr("style.test_video_prompt") if template_media_type == "video" else tr("style.test_prompt") |
| 848 | test_prompt_value = "a peaceful lake, gentle camera movement" if template_media_type == "video" else "a dog" |
| 849 | |
| 850 | test_prompt = st.text_input( |
| 851 | test_prompt_label, |
| 852 | value=test_prompt_value, |
| 853 | help=tr("style.test_prompt_help"), |
| 854 | key="style_test_prompt" |
| 855 | ) |
| 856 | |
| 857 | # Preview button |
| 858 | preview_button_label = tr("style.video_preview") if template_media_type == "video" else tr("style.preview") |
| 859 | if st.button(preview_button_label, key="preview_style", use_container_width=True): |
| 860 | if not workflow_key: |
| 861 | st.error( |
| 862 | "请先选择可用的工作流或模型。" |
| 863 | if get_language() == "zh_CN" |
| 864 | else "Please select an available workflow or model first." |
| 865 | ) |
| 866 | st.stop() |
| 867 | previewing_text = tr("style.video_previewing") if template_media_type == "video" else tr("style.previewing") |
| 868 | with st.spinner(previewing_text): |
| 869 | try: |
| 870 | from pixelle_video.utils.prompt_helper import build_image_prompt |
| 871 | |
| 872 | # Build final prompt with prefix |
| 873 | final_prompt = build_image_prompt(test_prompt, prompt_prefix) |
| 874 | |
| 875 | preview_params = dict(api_video_params) if template_media_type == "video" else {} |
| 876 | |
| 877 | # Generate preview media with the selected source only. |
| 878 | media_result = run_async(pixelle_video.media( |
| 879 | prompt=final_prompt, |
| 880 | workflow=workflow_key, |
| 881 | media_type=template_media_type, |
| 882 | width=int(media_width), |
| 883 | height=int(media_height), |
| 884 | duration=5 if template_media_type == "video" else None, |
| 885 | **preview_params, |
| 886 | )) |
| 887 | preview_media_path = media_result.url |
| 888 | |
| 889 | # Display preview (support both URL and local path) |
| 890 | if preview_media_path: |
| 891 | success_text = tr("style.video_preview_success") if template_media_type == "video" else tr("style.preview_success") |
| 892 | st.success(success_text) |
| 893 | |
| 894 | if template_media_type == "video": |
| 895 | st.video(preview_media_path) |
| 896 | else: |
| 897 | if preview_media_path.startswith('http'): |
| 898 | # URL - use directly |
| 899 | img_html = f'<div class="preview-image"><img src="{preview_media_path}" alt="Style Preview"/></div>' |
| 900 | else: |
| 901 | # Local file - encode as base64 |
| 902 | with open(preview_media_path, 'rb') as f: |
| 903 | img_data = base64.b64encode(f.read()).decode() |
| 904 | img_html = f'<div class="preview-image"><img src="data:image/png;base64,{img_data}" alt="Style Preview"/></div>' |
| 905 | |
| 906 | st.markdown(img_html, unsafe_allow_html=True) |
| 907 | |
| 908 | # Show the final prompt used |
| 909 | st.info(f"**{tr('style.final_prompt_label')}**\n{final_prompt}") |
| 910 | |
| 911 | # Show file path |
| 912 | st.caption(f"📁 {preview_media_path}") |
| 913 | else: |
| 914 | st.error(tr("style.preview_failed_general")) |
| 915 | except Exception as e: |
| 916 | st.error(tr("style.preview_failed", error=str(e))) |
| 917 | logger.exception(e) |
| 918 | |
| 919 | |
| 920 | else: |
| 921 | # Template doesn't need images - show simplified message |
| 922 | with st.container(border=True): |
| 923 | st.markdown(f"**{tr('section.image')}**") |
| 924 | st.info("ℹ️ " + tr("image.not_required")) |
| 925 | st.caption(tr("image.not_required_hint")) |
| 926 | |
| 927 | # Get media size from template (even though not used, for consistency) |
| 928 | media_width = st.session_state.get('template_media_width') |
| 929 | media_height = st.session_state.get('template_media_height') |
| 930 | |
| 931 | # Set default values for later use |
| 932 | workflow_key = None |
| 933 | prompt_prefix = "" |
| 934 | |
| 935 | # Return all style configuration parameters |
| 936 | final_media_workflow = workflow_key |
| 937 | |
| 938 | return { |
| 939 | "tts_inference_mode": tts_mode, |
| 940 | "tts_voice": selected_voice if tts_mode == "local" else None, |
| 941 | "tts_speed": tts_speed if tts_mode == "local" else None, |
| 942 | "tts_workflow": tts_workflow_key if tts_mode == "comfyui" else None, |
| 943 | "ref_audio": str(ref_audio_path) if ref_audio_path else None, |
| 944 | "frame_template": frame_template, |
| 945 | "template_params": custom_values_for_video if custom_values_for_video else None, |
| 946 | "media_workflow": final_media_workflow, |
| 947 | "api_video_params": api_video_params if template_media_type == "video" else None, |
| 948 | "prompt_prefix": prompt_prefix if prompt_prefix else "", |
| 949 | "media_width": media_width, |
| 950 | "media_height": media_height |
| 951 | } |
| 952 |