返回 Pixelle-Video
style_config.py
根目录 / web / components / style_config.py
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
952 lines PYTHON