| 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 |