asset_based.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  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. Asset-Based Pipeline UI
  14. Implements the UI for generating videos from user-provided assets.
  15. """
  16. import os
  17. import time
  18. from pathlib import Path
  19. from typing import Any
  20. import streamlit as st
  21. from loguru import logger
  22. from web.i18n import tr, get_language
  23. from web.pipelines.base import PipelineUI, register_pipeline_ui
  24. from web.components.content_input import render_bgm_section, render_version_info
  25. from web.utils.async_helpers import run_async
  26. from web.utils.streamlit_helpers import check_and_warn_selfhost_workflow
  27. from pixelle_video.config import config_manager
  28. from pixelle_video.models.progress import ProgressEvent
  29. class AssetBasedPipelineUI(PipelineUI):
  30. """
  31. UI for the Asset-Based Video Generation Pipeline.
  32. Generates videos from user-provided assets (images/videos).
  33. """
  34. name = "custom_media"
  35. icon = "🎨"
  36. @property
  37. def display_name(self):
  38. return tr("pipeline.custom_media.name")
  39. @property
  40. def description(self):
  41. return tr("pipeline.custom_media.description")
  42. def render(self, pixelle_video: Any):
  43. # Three-column layout
  44. left_col, middle_col, right_col = st.columns([1, 1, 1])
  45. # ====================================================================
  46. # Left Column: Asset Upload & Video Info
  47. # ====================================================================
  48. with left_col:
  49. asset_params = self._render_asset_input()
  50. bgm_params = render_bgm_section(key_prefix="asset_")
  51. render_version_info()
  52. # ====================================================================
  53. # Middle Column: Video Configuration
  54. # ====================================================================
  55. with middle_col:
  56. config_params = self._render_video_config(pixelle_video)
  57. # ====================================================================
  58. # Right Column: Output Preview
  59. # ====================================================================
  60. with right_col:
  61. # Combine all parameters
  62. video_params = {
  63. "pipeline": self.name,
  64. **asset_params,
  65. **bgm_params,
  66. **config_params
  67. }
  68. self._render_output_preview(pixelle_video, video_params)
  69. def _render_asset_input(self) -> dict:
  70. """Render asset upload section"""
  71. with st.container(border=True):
  72. st.markdown(f"**{tr('asset_based.section.assets')}**")
  73. with st.expander(tr("help.feature_description"), expanded=False):
  74. st.markdown(f"**{tr('help.what')}**")
  75. st.markdown(tr("asset_based.assets.what"))
  76. st.markdown(f"**{tr('help.how')}**")
  77. st.markdown(tr("asset_based.assets.how"))
  78. # File uploader for multiple files
  79. uploaded_files = st.file_uploader(
  80. tr("asset_based.assets.upload"),
  81. type=["jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "avi", "mkv", "webm"],
  82. accept_multiple_files=True,
  83. help=tr("asset_based.assets.upload_help"),
  84. key="asset_files"
  85. )
  86. # Save uploaded files to temp directory with unique session ID
  87. asset_paths = []
  88. if uploaded_files:
  89. import uuid
  90. session_id = str(uuid.uuid4()).replace('-', '')[:12]
  91. temp_dir = Path(f"temp/assets_{session_id}")
  92. temp_dir.mkdir(parents=True, exist_ok=True)
  93. for uploaded_file in uploaded_files:
  94. file_path = temp_dir / uploaded_file.name
  95. with open(file_path, "wb") as f:
  96. f.write(uploaded_file.getbuffer())
  97. asset_paths.append(str(file_path.absolute()))
  98. st.success(tr("asset_based.assets.count", count=len(asset_paths)))
  99. # Preview uploaded assets
  100. with st.expander(tr("asset_based.assets.preview"), expanded=True):
  101. # Show in a grid (3 columns)
  102. cols = st.columns(3)
  103. for i, (file, path) in enumerate(zip(uploaded_files, asset_paths)):
  104. with cols[i % 3]:
  105. # Check if image or video
  106. ext = Path(path).suffix.lower()
  107. if ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
  108. st.image(file, caption=file.name, use_container_width=True)
  109. elif ext in [".mp4", ".mov", ".avi", ".mkv", ".webm"]:
  110. st.video(file)
  111. st.caption(file.name)
  112. else:
  113. st.info(tr("asset_based.assets.empty_hint"))
  114. # Video title & intent
  115. with st.container(border=True):
  116. st.markdown(f"**{tr('asset_based.section.video_info')}**")
  117. video_title = st.text_input(
  118. tr("asset_based.video_title"),
  119. placeholder=tr("asset_based.video_title_placeholder"),
  120. help=tr("asset_based.video_title_help"),
  121. key="asset_video_title"
  122. )
  123. intent = st.text_area(
  124. tr("asset_based.intent"),
  125. placeholder=tr("asset_based.intent_placeholder"),
  126. help=tr("asset_based.intent_help"),
  127. height=100,
  128. key="asset_intent"
  129. )
  130. return {
  131. "assets": asset_paths,
  132. "video_title": video_title,
  133. "intent": intent if intent else None
  134. }
  135. def _render_video_config(self, pixelle_video: Any) -> dict:
  136. """Render video configuration section"""
  137. # Duration configuration
  138. with st.container(border=True):
  139. st.markdown(f"**{tr('video.title')}**")
  140. # Duration slider
  141. duration = st.slider(
  142. tr("asset_based.duration"),
  143. min_value=15,
  144. max_value=120,
  145. value=30,
  146. step=5,
  147. help=tr("asset_based.duration_help"),
  148. key="asset_duration"
  149. )
  150. st.caption(tr("asset_based.duration_label", seconds=duration))
  151. # Workflow source selection
  152. with st.container(border=True):
  153. st.markdown(f"**{tr('asset_based.section.source')}**")
  154. with st.expander(tr("help.feature_description"), expanded=False):
  155. st.markdown(f"**{tr('help.what')}**")
  156. st.markdown(tr("asset_based.source.what"))
  157. st.markdown(f"**{tr('help.how')}**")
  158. st.markdown(tr("asset_based.source.how"))
  159. source_options = {
  160. "runninghub": tr("asset_based.source.runninghub"),
  161. "selfhost": tr("asset_based.source.selfhost")
  162. }
  163. # Check if RunningHub API key is configured
  164. comfyui_config = config_manager.get_comfyui_config()
  165. has_runninghub = bool(comfyui_config.get("runninghub_api_key"))
  166. has_selfhost = bool(comfyui_config.get("comfyui_url"))
  167. # Default to runninghub always
  168. default_source_index = 0
  169. source = st.radio(
  170. tr("asset_based.source.select"),
  171. options=list(source_options.keys()),
  172. format_func=lambda x: source_options[x],
  173. index=default_source_index,
  174. horizontal=True,
  175. key="asset_source",
  176. label_visibility="collapsed"
  177. )
  178. # Show hint based on selection
  179. if source == "runninghub":
  180. if not has_runninghub:
  181. st.warning(tr("asset_based.source.runninghub_not_configured"))
  182. else:
  183. st.info(tr("asset_based.source.runninghub_hint"))
  184. else:
  185. if not has_selfhost:
  186. st.warning(tr("asset_based.source.selfhost_not_configured"))
  187. else:
  188. st.info(tr("asset_based.source.selfhost_hint"))
  189. # Check and warn for selfhost mode (auto popup if not confirmed)
  190. # Use analyse_image.json as representative workflow
  191. check_and_warn_selfhost_workflow("selfhost/analyse_image.json")
  192. # TTS configuration
  193. with st.container(border=True):
  194. st.markdown(f"**{tr('section.tts')}**")
  195. # Import voice configuration
  196. from pixelle_video.tts_voices import EDGE_TTS_VOICES, get_voice_display_name
  197. # Get saved voice from config
  198. comfyui_config = config_manager.get_comfyui_config()
  199. tts_config = comfyui_config.get("tts", {})
  200. local_config = tts_config.get("local", {})
  201. saved_voice = local_config.get("voice", "zh-CN-YunjianNeural")
  202. saved_speed = local_config.get("speed", 1.2)
  203. # Build voice options with i18n
  204. voice_options = []
  205. voice_ids = []
  206. default_voice_index = 0
  207. for idx, voice_config in enumerate(EDGE_TTS_VOICES):
  208. voice_id = voice_config["id"]
  209. display_name = get_voice_display_name(voice_id, tr, get_language())
  210. voice_options.append(display_name)
  211. voice_ids.append(voice_id)
  212. if voice_id == saved_voice:
  213. default_voice_index = idx
  214. # Two-column layout
  215. voice_col, speed_col = st.columns([1, 1])
  216. with voice_col:
  217. selected_voice_display = st.selectbox(
  218. tr("tts.voice_selector"),
  219. voice_options,
  220. index=default_voice_index,
  221. key="asset_tts_voice"
  222. )
  223. selected_voice_index = voice_options.index(selected_voice_display)
  224. voice_id = voice_ids[selected_voice_index]
  225. with speed_col:
  226. tts_speed = st.slider(
  227. tr("tts.speed"),
  228. min_value=0.5,
  229. max_value=2.0,
  230. value=saved_speed,
  231. step=0.1,
  232. format="%.1fx",
  233. key="asset_tts_speed"
  234. )
  235. st.caption(tr("tts.speed_label", speed=f"{tts_speed:.1f}"))
  236. return {
  237. "duration": duration,
  238. "source": source,
  239. "voice_id": voice_id,
  240. "tts_speed": tts_speed
  241. }
  242. def _render_output_preview(self, pixelle_video: Any, video_params: dict):
  243. """Render output preview section"""
  244. with st.container(border=True):
  245. st.markdown(f"**{tr('section.video_generation')}**")
  246. # Check configuration
  247. if not config_manager.validate():
  248. st.warning(tr("settings.not_configured"))
  249. # Check if assets are provided
  250. assets = video_params.get("assets", [])
  251. if not assets:
  252. st.info(tr("asset_based.output.no_assets"))
  253. st.button(
  254. tr("btn.generate"),
  255. type="primary",
  256. use_container_width=True,
  257. disabled=True,
  258. key="asset_generate_disabled"
  259. )
  260. return
  261. # Show asset summary
  262. st.info(tr("asset_based.output.ready", count=len(assets)))
  263. # Generate button
  264. if st.button(tr("btn.generate"), type="primary", use_container_width=True, key="asset_generate"):
  265. # Validate
  266. if not config_manager.validate():
  267. st.error(tr("settings.not_configured"))
  268. st.stop()
  269. # Show progress
  270. progress_bar = st.progress(0)
  271. status_text = st.empty()
  272. start_time = time.time()
  273. try:
  274. # Import pipeline
  275. from pixelle_video.pipelines.asset_based import AssetBasedPipeline
  276. # Create pipeline
  277. pipeline = AssetBasedPipeline(pixelle_video)
  278. # Progress callback
  279. def update_progress(event: ProgressEvent):
  280. if event.event_type == "analyzing_assets":
  281. if event.extra_info == "start":
  282. message = tr("asset_based.progress.analyzing_start", total=event.frame_total)
  283. else:
  284. message = tr("asset_based.progress.analyzing_complete", count=event.frame_total)
  285. elif event.event_type == "analyzing_asset":
  286. message = tr(
  287. "asset_based.progress.analyzing_asset",
  288. current=event.frame_current,
  289. total=event.frame_total,
  290. name=event.extra_info or ""
  291. )
  292. elif event.event_type == "generating_script":
  293. if event.extra_info == "complete":
  294. message = tr("asset_based.progress.script_complete")
  295. else:
  296. message = tr("asset_based.progress.generating_script")
  297. elif event.event_type == "frame_step":
  298. action_key = f"progress.step_{event.action}"
  299. action_text = tr(action_key)
  300. message = tr(
  301. "progress.frame_step",
  302. current=event.frame_current,
  303. total=event.frame_total,
  304. step=event.step,
  305. action=action_text
  306. )
  307. elif event.event_type == "processing_frame":
  308. message = tr(
  309. "progress.frame",
  310. current=event.frame_current,
  311. total=event.frame_total
  312. )
  313. elif event.event_type == "concatenating":
  314. if event.extra_info == "complete":
  315. message = tr("asset_based.progress.concat_complete")
  316. else:
  317. message = tr("progress.concatenating")
  318. elif event.event_type == "completed":
  319. message = tr("progress.completed")
  320. else:
  321. message = tr(f"progress.{event.event_type}")
  322. status_text.text(message)
  323. progress_bar.progress(min(int(event.progress * 100), 99))
  324. # Execute pipeline with progress callback
  325. ctx = run_async(pipeline(
  326. assets=video_params["assets"],
  327. video_title=video_params.get("video_title", ""),
  328. intent=video_params.get("intent"),
  329. duration=video_params.get("duration", 30),
  330. source=video_params.get("source", "runninghub"),
  331. bgm_path=video_params.get("bgm_path"),
  332. bgm_volume=video_params.get("bgm_volume", 0.2),
  333. bgm_mode=video_params.get("bgm_mode", "loop"),
  334. voice_id=video_params.get("voice_id", "zh-CN-YunjianNeural"),
  335. tts_speed=video_params.get("tts_speed", 1.2),
  336. progress_callback=update_progress
  337. ))
  338. total_time = time.time() - start_time
  339. progress_bar.progress(100)
  340. status_text.text(tr("status.success"))
  341. # Display result
  342. st.success(tr("status.video_generated", path=ctx.final_video_path))
  343. st.markdown("---")
  344. # Video info
  345. if os.path.exists(ctx.final_video_path):
  346. file_size_mb = os.path.getsize(ctx.final_video_path) / (1024 * 1024)
  347. n_scenes = len(ctx.storyboard.frames) if ctx.storyboard else 0
  348. info_text = (
  349. f"⏱️ {tr('info.generation_time')} {total_time:.1f}s "
  350. f"📦 {file_size_mb:.2f}MB "
  351. f"🎬 {n_scenes}{tr('info.scenes_unit')}"
  352. )
  353. st.caption(info_text)
  354. st.markdown("---")
  355. # Video preview
  356. st.video(ctx.final_video_path)
  357. # Download button
  358. with open(ctx.final_video_path, "rb") as video_file:
  359. video_bytes = video_file.read()
  360. video_filename = os.path.basename(ctx.final_video_path)
  361. st.download_button(
  362. label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
  363. data=video_bytes,
  364. file_name=video_filename,
  365. mime="video/mp4",
  366. use_container_width=True
  367. )
  368. else:
  369. st.error(tr("status.video_not_found", path=ctx.final_video_path))
  370. except Exception as e:
  371. status_text.text("")
  372. progress_bar.empty()
  373. st.error(tr("status.error", error=str(e)))
  374. logger.exception(e)
  375. st.stop()
  376. # Register self
  377. register_pipeline_ui(AssetBasedPipelineUI)