返回 Pixelle-Video
asset_based.py
根目录 / web / pipelines / asset_based.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 Asset-Based Pipeline UI
15
16 Implements the UI for generating videos from user-provided assets.
17 """
18
19 import os
20 import time
21 from pathlib import Path
22 from typing import Any
23
24 import streamlit as st
25 from loguru import logger
26
27 from web.i18n import tr, get_language
28 from web.pipelines.base import PipelineUI, register_pipeline_ui
29 from web.pipelines.api_workflows import (
30 list_api_media_workflows,
31 render_api_video_controls,
32 workflow_select_help,
33 workflow_source_help,
34 workflow_source_label,
35 )
36 from web.components.content_input import render_bgm_section, render_version_info
37 from web.utils.async_helpers import run_async
38 from web.utils.streamlit_helpers import check_and_warn_selfhost_workflow
39 from pixelle_video.config import config_manager
40 from pixelle_video.models.progress import ProgressEvent
41
42
43 class AssetBasedPipelineUI(PipelineUI):
44 """
45 UI for the Asset-Based Video Generation Pipeline.
46 Generates videos from user-provided assets (images/videos).
47 """
48 name = "custom_media"
49 icon = "🎨"
50
51 @property
52 def display_name(self):
53 return tr("pipeline.custom_media.name")
54
55 @property
56 def description(self):
57 return tr("pipeline.custom_media.description")
58
59 def render(self, pixelle_video: Any):
60 # Three-column layout
61 left_col, middle_col, right_col = st.columns([1, 1, 1])
62
63 # ====================================================================
64 # Left Column: Asset Upload & Video Info
65 # ====================================================================
66 with left_col:
67 asset_params = self._render_asset_input()
68 bgm_params = render_bgm_section(key_prefix="asset_")
69 render_version_info()
70
71 # ====================================================================
72 # Middle Column: Video Configuration
73 # ====================================================================
74 with middle_col:
75 config_params = self._render_video_config(pixelle_video, asset_params)
76
77 # ====================================================================
78 # Right Column: Output Preview
79 # ====================================================================
80 with right_col:
81 # Combine all parameters
82 video_params = {
83 "pipeline": self.name,
84 **asset_params,
85 **bgm_params,
86 **config_params
87 }
88
89 self._render_output_preview(pixelle_video, video_params)
90
91 def _render_asset_input(self) -> dict:
92 """Render asset upload section"""
93 with st.container(border=True):
94 st.markdown(f"**{tr('asset_based.section.assets')}**")
95
96 with st.expander(tr("help.feature_description"), expanded=False):
97 st.markdown(f"**{tr('help.what')}**")
98 st.markdown(tr("asset_based.assets.what"))
99 st.markdown(f"**{tr('help.how')}**")
100 st.markdown(tr("asset_based.assets.how"))
101
102 # File uploader for multiple files
103 uploaded_files = st.file_uploader(
104 tr("asset_based.assets.upload"),
105 type=["jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "avi", "mkv", "webm"],
106 accept_multiple_files=True,
107 help=tr("asset_based.assets.upload_help"),
108 key="asset_files"
109 )
110
111 # Save uploaded files to temp directory with unique session ID
112 asset_paths = []
113 if uploaded_files:
114 import uuid
115 session_id = str(uuid.uuid4()).replace('-', '')[:12]
116 temp_dir = Path(f"temp/assets_{session_id}")
117 temp_dir.mkdir(parents=True, exist_ok=True)
118
119 for uploaded_file in uploaded_files:
120 file_path = temp_dir / uploaded_file.name
121 with open(file_path, "wb") as f:
122 f.write(uploaded_file.getbuffer())
123 asset_paths.append(str(file_path.absolute()))
124
125 st.success(tr("asset_based.assets.count", count=len(asset_paths)))
126
127 # Preview uploaded assets
128 with st.expander(tr("asset_based.assets.preview"), expanded=True):
129 # Show in a grid (3 columns)
130 cols = st.columns(3)
131 for i, (file, path) in enumerate(zip(uploaded_files, asset_paths)):
132 with cols[i % 3]:
133 # Check if image or video
134 ext = Path(path).suffix.lower()
135 if ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
136 st.image(file, caption=file.name, use_container_width=True)
137 elif ext in [".mp4", ".mov", ".avi", ".mkv", ".webm"]:
138 st.video(file)
139 st.caption(file.name)
140 else:
141 st.info(tr("asset_based.assets.empty_hint"))
142
143 # Video title & intent
144 with st.container(border=True):
145 st.markdown(f"**{tr('asset_based.section.video_info')}**")
146
147 video_title = st.text_input(
148 tr("asset_based.video_title"),
149 placeholder=tr("asset_based.video_title_placeholder"),
150 help=tr("asset_based.video_title_help"),
151 key="asset_video_title"
152 )
153
154 intent = st.text_area(
155 tr("asset_based.intent"),
156 placeholder=tr("asset_based.intent_placeholder"),
157 help=tr("asset_based.intent_help"),
158 height=100,
159 key="asset_intent"
160 )
161
162 return {
163 "assets": asset_paths,
164 "video_title": video_title,
165 "intent": intent if intent else None
166 }
167
168 def _render_video_config(self, pixelle_video: Any, asset_params: dict | None = None) -> dict:
169 """Render video configuration section"""
170 # Duration configuration
171 with st.container(border=True):
172 st.markdown(f"**{tr('video.title')}**")
173
174 # Duration slider
175 duration = st.slider(
176 tr("asset_based.duration"),
177 min_value=15,
178 max_value=120,
179 value=30,
180 step=5,
181 help=tr("asset_based.duration_help"),
182 key="asset_duration"
183 )
184 st.caption(tr("asset_based.duration_label", seconds=duration))
185
186 # Workflow source selection
187 with st.container(border=True):
188 st.markdown(f"**{tr('asset_based.section.source')}**")
189
190 with st.expander(tr("help.feature_description"), expanded=False):
191 st.markdown(f"**{tr('help.what')}**")
192 st.markdown(tr("asset_based.source.what"))
193 st.markdown(f"**{tr('help.how')}**")
194 st.markdown(tr("asset_based.source.how"))
195
196 source_options = {
197 "runninghub": tr("asset_based.source.runninghub"),
198 "selfhost": tr("asset_based.source.selfhost"),
199 "api": "API 调用" if get_language() == "zh_CN" else "API call",
200 }
201
202 # Check if RunningHub API key is configured
203 comfyui_config = config_manager.get_comfyui_config()
204 api_asset_analysis = getattr(pixelle_video, "api_asset_analysis", None)
205 api_vlm_models = (
206 api_asset_analysis.list_models(configured_only=True)
207 if api_asset_analysis is not None
208 else []
209 )
210 has_runninghub = bool(comfyui_config.get("runninghub_api_key"))
211 has_selfhost = bool(comfyui_config.get("comfyui_url"))
212 has_api_analysis = bool(api_vlm_models)
213
214 asset_paths = (asset_params or {}).get("assets") or []
215 image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
216 video_exts = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
217 has_image_assets = any(Path(path).suffix.lower() in image_exts for path in asset_paths)
218 has_video_assets = any(Path(path).suffix.lower() in video_exts for path in asset_paths)
219
220 def analysis_source_available(source_name: str) -> bool:
221 source_dir = Path("workflows") / source_name
222 image_available = (source_dir / "analyse_image.json").exists()
223 video_available = (source_dir / "analyse_video.json").exists()
224 if has_image_assets and not image_available:
225 return False
226 if has_video_assets and not video_available:
227 return False
228 return image_available or video_available
229
230 # Prefer API VLM when configured, so API media workflows do not depend on RunningHub.
231 source_keys = []
232 if analysis_source_available("runninghub"):
233 source_keys.append("runninghub")
234 if analysis_source_available("selfhost"):
235 source_keys.append("selfhost")
236 if has_api_analysis:
237 source_keys.append("api")
238 if not source_keys:
239 source_keys = ["runninghub"]
240
241 if has_api_analysis and "api" in source_keys:
242 default_source = "api"
243 elif has_runninghub and "runninghub" in source_keys:
244 default_source = "runninghub"
245 elif "selfhost" in source_keys:
246 default_source = "selfhost"
247 else:
248 default_source = source_keys[0]
249 default_source_index = source_keys.index(default_source)
250
251 if st.session_state.get("asset_source") not in source_keys:
252 st.session_state.pop("asset_source", None)
253
254 source = st.radio(
255 "素材分析服务" if get_language() == "zh_CN" else "Asset analysis service",
256 options=source_keys,
257 format_func=lambda x: source_options[x],
258 index=default_source_index,
259 horizontal=True,
260 key="asset_source",
261 label_visibility="visible",
262 help=workflow_source_help("素材分析" if get_language() == "zh_CN" else "asset analysis"),
263 )
264
265 def build_analysis_workflows(source_name: str) -> list[dict]:
266 if source_name == "api":
267 return [
268 {
269 "display_name": model_info["display_name"],
270 "image_workflow": None,
271 "video_workflow": None,
272 "model": model_info["model"],
273 }
274 for model_info in api_vlm_models
275 ]
276
277 source_dir = Path("workflows") / source_name
278 needs_image = has_image_assets or not asset_paths
279 needs_video = has_video_assets or not asset_paths
280 image_workflow = None
281 video_workflow = None
282 workflow_names = []
283
284 if needs_image and (source_dir / "analyse_image.json").exists():
285 image_workflow = f"{source_name}/analyse_image.json"
286 workflow_names.append("analyse_image.json")
287 if needs_video and (source_dir / "analyse_video.json").exists():
288 video_workflow = f"{source_name}/analyse_video.json"
289 workflow_names.append("analyse_video.json")
290
291 if not workflow_names:
292 return []
293
294 return [{
295 "display_name": f"{' + '.join(workflow_names)} - {workflow_source_label(source_name)}",
296 "image_workflow": image_workflow,
297 "video_workflow": video_workflow,
298 "model": None,
299 }]
300
301 analysis_workflows = build_analysis_workflows(source)
302 analysis_options = [workflow["display_name"] for workflow in analysis_workflows]
303 selected_analysis_workflow = {}
304
305 if st.session_state.get("asset_analysis_workflow") not in analysis_options:
306 st.session_state.pop("asset_analysis_workflow", None)
307
308 if analysis_options:
309 selected_analysis = st.selectbox(
310 "素材分析工作流/模型" if get_language() == "zh_CN" else "Asset analysis workflow/model",
311 analysis_options,
312 index=0,
313 key="asset_analysis_workflow",
314 help=workflow_select_help(),
315 )
316 selected_analysis_workflow = analysis_workflows[analysis_options.index(selected_analysis)]
317 else:
318 st.warning(
319 "当前服务没有可用的素材分析工作流/模型。"
320 if get_language() == "zh_CN"
321 else "No asset analysis workflow/model is available for the selected service."
322 )
323
324 # Show hint based on selection
325 if source == "api":
326 if not has_api_analysis:
327 st.warning(
328 "未配置可用于 VLM 素材分析的 API Key(DashScope/OpenAI/Gemini)。"
329 if get_language() == "zh_CN"
330 else "No API key configured for VLM asset analysis (DashScope/OpenAI/Gemini)."
331 )
332 else:
333 st.info(
334 "使用上方选择的 API VLM 模型分析上传素材,不依赖 RunningHub/ComfyUI。"
335 if get_language() == "zh_CN"
336 else "Use the selected API VLM model to analyze uploaded assets without RunningHub/ComfyUI."
337 )
338 elif source == "runninghub":
339 if not has_runninghub:
340 st.warning(tr("asset_based.source.runninghub_not_configured"))
341 else:
342 st.info(tr("asset_based.source.runninghub_hint"))
343 else:
344 if not has_selfhost:
345 st.warning(tr("asset_based.source.selfhost_not_configured"))
346 else:
347 st.info(tr("asset_based.source.selfhost_hint"))
348 # Check and warn for selfhost mode (auto popup if not confirmed)
349 workflow_for_warning = (
350 selected_analysis_workflow.get("image_workflow")
351 or selected_analysis_workflow.get("video_workflow")
352 )
353 if workflow_for_warning:
354 check_and_warn_selfhost_workflow(workflow_for_warning)
355
356 api_video_workflow = None
357 api_video_params = {}
358 api_video_workflows = list_api_media_workflows(
359 pixelle_video,
360 "video",
361 required_adapter_abilities=["first_frame_i2v"],
362 verified_only=True,
363 )
364 animation_source_options = ["none"]
365 if api_video_workflows:
366 animation_source_options.append("api")
367
368 if st.session_state.get("asset_animation_source") not in animation_source_options:
369 st.session_state.pop("asset_animation_source", None)
370
371 def animation_source_label(value: str) -> str:
372 if value == "none":
373 return "不启用" if get_language() == "zh_CN" else "Disabled"
374 return workflow_source_label(value)
375
376 animation_source = st.radio(
377 "素材动画服务" if get_language() == "zh_CN" else "Asset animation service",
378 animation_source_options,
379 format_func=animation_source_label,
380 horizontal=True,
381 key="asset_animation_source",
382 help=(
383 "选择是否把匹配到的图片素材动画化。不启用时保留原素材静态合成;API 模型会调用已验证的图生视频模型。"
384 if get_language() == "zh_CN"
385 else "Choose whether to animate matched image assets. Disabled keeps the original static asset composition; API models call verified image-to-video providers."
386 ),
387 )
388
389 if animation_source == "api":
390 animation_workflows = api_video_workflows
391 animation_options = [wf["display_name"] for wf in animation_workflows]
392 selected_animation = st.selectbox(
393 "素材动画工作流/模型" if get_language() == "zh_CN" else "Asset animation workflow/model",
394 animation_options,
395 index=0,
396 key="asset_animation_workflow",
397 help=workflow_select_help(),
398 )
399 selected_index = animation_options.index(selected_animation)
400 selected_workflow = animation_workflows[selected_index]
401 api_video_workflow = selected_workflow["key"]
402 api_video_params = render_api_video_controls(
403 selected_workflow,
404 key_prefix="asset",
405 default_duration=5,
406 allow_audio_driven=True,
407 show_duration=False,
408 )
409
410 # TTS configuration
411 with st.container(border=True):
412 st.markdown(f"**{tr('section.tts')}**")
413
414 # Import voice configuration
415 from pixelle_video.tts_voices import EDGE_TTS_VOICES, get_voice_display_name
416
417 # Get saved voice from config
418 comfyui_config = config_manager.get_comfyui_config()
419 tts_config = comfyui_config.get("tts", {})
420 local_config = tts_config.get("local", {})
421 saved_voice = local_config.get("voice", "zh-CN-YunjianNeural")
422 saved_speed = local_config.get("speed", 1.2)
423
424 # Build voice options with i18n
425 voice_options = []
426 voice_ids = []
427 default_voice_index = 0
428
429 for idx, voice_config in enumerate(EDGE_TTS_VOICES):
430 voice_id = voice_config["id"]
431 display_name = get_voice_display_name(voice_id, tr, get_language())
432 voice_options.append(display_name)
433 voice_ids.append(voice_id)
434
435 if voice_id == saved_voice:
436 default_voice_index = idx
437
438 # Two-column layout
439 voice_col, speed_col = st.columns([1, 1])
440
441 with voice_col:
442 selected_voice_display = st.selectbox(
443 tr("tts.voice_selector"),
444 voice_options,
445 index=default_voice_index,
446 key="asset_tts_voice"
447 )
448 selected_voice_index = voice_options.index(selected_voice_display)
449 voice_id = voice_ids[selected_voice_index]
450
451 with speed_col:
452 tts_speed = st.slider(
453 tr("tts.speed"),
454 min_value=0.5,
455 max_value=2.0,
456 value=saved_speed,
457 step=0.1,
458 format="%.1fx",
459 key="asset_tts_speed"
460 )
461 st.caption(tr("tts.speed_label", speed=f"{tts_speed:.1f}"))
462
463 return {
464 "duration": duration,
465 "source": source,
466 "analysis_image_workflow": selected_analysis_workflow.get("image_workflow"),
467 "analysis_video_workflow": selected_analysis_workflow.get("video_workflow"),
468 "analysis_vlm_model": selected_analysis_workflow.get("model"),
469 "api_video_workflow": api_video_workflow,
470 "api_video_params": api_video_params,
471 "voice_id": voice_id,
472 "tts_speed": tts_speed
473 }
474
475 def _render_output_preview(self, pixelle_video: Any, video_params: dict):
476 """Render output preview section"""
477 with st.container(border=True):
478 st.markdown(f"**{tr('section.video_generation')}**")
479
480 # Check configuration
481 if not config_manager.validate():
482 st.warning(tr("settings.not_configured"))
483
484 # Check if assets are provided
485 assets = video_params.get("assets", [])
486 if not assets:
487 st.info(tr("asset_based.output.no_assets"))
488 st.button(
489 tr("btn.generate"),
490 type="primary",
491 use_container_width=True,
492 disabled=True,
493 key="asset_generate_disabled"
494 )
495 return
496
497 # Show asset summary
498 st.info(tr("asset_based.output.ready", count=len(assets)))
499
500 # Generate button
501 if st.button(tr("btn.generate"), type="primary", use_container_width=True, key="asset_generate"):
502 # Validate
503 if not config_manager.validate():
504 st.error(tr("settings.not_configured"))
505 st.stop()
506
507 # Show progress
508 progress_bar = st.progress(0)
509 status_text = st.empty()
510
511 start_time = time.time()
512
513 try:
514 # Import pipeline
515 from pixelle_video.pipelines.asset_based import AssetBasedPipeline
516
517 # Create pipeline
518 pipeline = AssetBasedPipeline(pixelle_video)
519
520 # Progress callback
521 def update_progress(event: ProgressEvent):
522 if event.event_type == "analyzing_assets":
523 if event.extra_info == "start":
524 message = tr("asset_based.progress.analyzing_start", total=event.frame_total)
525 else:
526 message = tr("asset_based.progress.analyzing_complete", count=event.frame_total)
527 elif event.event_type == "analyzing_asset":
528 message = tr(
529 "asset_based.progress.analyzing_asset",
530 current=event.frame_current,
531 total=event.frame_total,
532 name=event.extra_info or ""
533 )
534 elif event.event_type == "generating_script":
535 if event.extra_info == "complete":
536 message = tr("asset_based.progress.script_complete")
537 else:
538 message = tr("asset_based.progress.generating_script")
539 elif event.event_type == "frame_step":
540 action_key = f"progress.step_{event.action}"
541 action_text = tr(action_key)
542 message = tr(
543 "progress.frame_step",
544 current=event.frame_current,
545 total=event.frame_total,
546 step=event.step,
547 action=action_text
548 )
549 elif event.event_type == "processing_frame":
550 message = tr(
551 "progress.frame",
552 current=event.frame_current,
553 total=event.frame_total
554 )
555 elif event.event_type == "concatenating":
556 if event.extra_info == "complete":
557 message = tr("asset_based.progress.concat_complete")
558 else:
559 message = tr("progress.concatenating")
560 elif event.event_type == "completed":
561 message = tr("progress.completed")
562 else:
563 message = tr(f"progress.{event.event_type}")
564
565 status_text.text(message)
566 progress_bar.progress(min(int(event.progress * 100), 99))
567
568 # Execute pipeline with progress callback
569 if video_params.get("source") == "api" and not video_params.get("analysis_vlm_model"):
570 raise RuntimeError(
571 "请先在素材分析服务中选择 API VLM 模型。"
572 if get_language() == "zh_CN"
573 else "Please select an API VLM model in the asset analysis service settings."
574 )
575
576 ctx = run_async(pipeline(
577 assets=video_params["assets"],
578 video_title=video_params.get("video_title", ""),
579 intent=video_params.get("intent"),
580 duration=video_params.get("duration", 30),
581 source=video_params.get("source", "runninghub"),
582 analysis_image_workflow=video_params.get("analysis_image_workflow"),
583 analysis_video_workflow=video_params.get("analysis_video_workflow"),
584 analysis_vlm_model=video_params.get("analysis_vlm_model"),
585 bgm_path=video_params.get("bgm_path"),
586 bgm_volume=video_params.get("bgm_volume", 0.2),
587 bgm_mode=video_params.get("bgm_mode", "loop"),
588 api_video_workflow=video_params.get("api_video_workflow"),
589 api_video_params=video_params.get("api_video_params"),
590 voice_id=video_params.get("voice_id", "zh-CN-YunjianNeural"),
591 tts_speed=video_params.get("tts_speed", 1.2),
592 progress_callback=update_progress
593 ))
594
595 total_time = time.time() - start_time
596
597 progress_bar.progress(100)
598 status_text.text(tr("status.success"))
599
600 # Display result
601 st.success(tr("status.video_generated", path=ctx.final_video_path))
602
603 st.markdown("---")
604
605 # Video info
606 if os.path.exists(ctx.final_video_path):
607 file_size_mb = os.path.getsize(ctx.final_video_path) / (1024 * 1024)
608 n_scenes = len(ctx.storyboard.frames) if ctx.storyboard else 0
609
610 info_text = (
611 f"⏱️ {tr('info.generation_time')} {total_time:.1f}s "
612 f"📦 {file_size_mb:.2f}MB "
613 f"🎬 {n_scenes}{tr('info.scenes_unit')}"
614 )
615 st.caption(info_text)
616
617 st.markdown("---")
618
619 # Video preview
620 st.video(ctx.final_video_path)
621
622 # Download button
623 with open(ctx.final_video_path, "rb") as video_file:
624 video_bytes = video_file.read()
625 video_filename = os.path.basename(ctx.final_video_path)
626 st.download_button(
627 label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
628 data=video_bytes,
629 file_name=video_filename,
630 mime="video/mp4",
631 use_container_width=True
632 )
633 else:
634 st.error(tr("status.video_not_found", path=ctx.final_video_path))
635
636 except Exception as e:
637 status_text.text("")
638 progress_bar.empty()
639 st.error(tr("status.error", error=str(e)))
640 logger.exception(e)
641 st.stop()
642
643
644 # Register self
645 register_pipeline_ui(AssetBasedPipelineUI)
646
646 lines PYTHON