diff --git a/pixelle_video/tts_voices.py b/pixelle_video/tts_voices.py index c94a3a8..02ecf20 100644 --- a/pixelle_video/tts_voices.py +++ b/pixelle_video/tts_voices.py @@ -70,8 +70,6 @@ EDGE_TTS_VOICES: List[Dict[str, Any]] = [ "locale": "zh-CN", "gender": "female" }, - - # English voices { "id": "en-US-AriaNeural", "label_key": "tts.voice.en_US_AriaNeural", @@ -108,6 +106,92 @@ EDGE_TTS_VOICES: List[Dict[str, Any]] = [ "locale": "en-GB", "gender": "male" }, + { + "id": "ko-KR-InJoonNeural", + "label_key": "tts.voice.ko-KR-InJoonNeural", + "locale": "ko-KR", + "gender": "male" + }, + { + "id": "ko-KR-SunHiNeural", + "label_key": "tts.voice.ko-KR-SunHiNeural", + "locale": "ko-KR", + "gender": "female" + }, + { + "id": "fr-FR-EloiseNeural", + "label_key": "tts.voice.fr-FR-EloiseNeural", + "locale": "fr-FR", + "gender": "female" + }, + { + "id": "fr-FR-HenriNeural", + "label_key": "tts.voice.fr-FR-HenriNeural", + "locale": "fr-FR", + "gender": "male" + }, + { + "id": "pt-PT-DuarteNeural", + "label_key": "tts.voice.pt-PT-DuarteNeural", + "locale": "pt-PT", + "gender": "male" + }, + { + "id": "pt-PT-RaquelNeural", + "label_key": "tts.voice.pt-PT-RaquelNeural", + "locale": "pt-PT", + "gender": "female" + }, + { + "id": "de-DE-AmalaNeural", + "label_key": "tts.voice.de-DE-AmalaNeural", + "locale": "de-DE", + "gender": "female" + }, + { + "id": "de-DE-ConradNeural", + "label_key": "tts.voice.de-DE-ConradNeural", + "locale": "de-DE", + "gender": "male" + }, + + # English voices + { + "id": "ru-RU-DmitryNeural", + "label_key": "tts.voice.ru-RU-DmitryNeural", + "locale": "ru-RU", + "gender": "male" + }, + { + "id": "ru-RU-SvetlanaNeural", + "label_key": "tts.voice.ru-RU-SvetlanaNeural", + "locale": "ru-RU", + "gender": "female" + }, + { + "id": "tr-TR-AhmetNeural", + "label_key": "tts.voice.tr-TR-AhmetNeural", + "locale": "tr-TR", + "gender": "male" + }, + { + "id": "tr-TR-EmelNeural", + "label_key": "tts.voice.tr-TR-EmelNeural", + "locale": "tr-TR", + "gender": "female" + }, + { + "id": "es-ES-AlvaroNeural", + "label_key": "tts.voice.es-ES-AlvaroNeural", + "locale": "es-ES", + "gender": "male" + }, + { + "id": "es-ES-ElviraNeural", + "label_key": "tts.voice.es-ES-ElviraNeural", + "locale": "es-ES", + "gender": "female" + }, ] diff --git a/web/components/digital_tts_config.py b/web/components/digital_tts_config.py new file mode 100644 index 0000000..607dc2c --- /dev/null +++ b/web/components/digital_tts_config.py @@ -0,0 +1,148 @@ +# Copyright (C) 2025 AIDC-AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Style configuration components for web UI (middle column) +""" + +import os +from pathlib import Path + +import streamlit as st +from loguru import logger + +from web.i18n import tr, get_language +from web.utils.async_helpers import run_async +from pixelle_video.config import config_manager + + +def render_style_config(pixelle_video): + """Render style configuration section (middle column)""" + # TTS Section (moved from left column) + # ==================================================================== + with st.container(border=True): + st.markdown(f"**{tr('section.tts')}**") + + with st.expander(tr("help.feature_description"), expanded=False): + st.markdown(f"**{tr('help.what')}**") + st.markdown(tr("digital_tts.what")) + + # Get TTS config + comfyui_config = config_manager.get_comfyui_config() + tts_config = comfyui_config["tts"] + + tts_mode = "local" + + # ================================================================ + # Local Mode UI (simple) + # ================================================================ + # Import voice configuration + from pixelle_video.tts_voices import EDGE_TTS_VOICES, get_voice_display_name + + # Get saved voice from config + local_config = tts_config.get("local", {}) + saved_voice = local_config.get("voice", "zh-CN-YunjianNeural") + saved_speed = local_config.get("speed", 1.2) + + # Build voice options with i18n + voice_options = [] + voice_ids = [] + default_voice_index = 0 + + for idx, voice_config in enumerate(EDGE_TTS_VOICES): + voice_id = voice_config["id"] + display_name = get_voice_display_name(voice_id, tr, get_language()) + voice_options.append(display_name) + voice_ids.append(voice_id) + + # Set default index if matches saved voice + if voice_id == saved_voice: + default_voice_index = idx + + # Two-column layout: Voice | Speed + voice_col, speed_col = st.columns([1, 1]) + + with voice_col: + # Voice selector + selected_voice_display = st.selectbox( + tr("tts.voice_selector"), + voice_options, + index=default_voice_index, + key="digital_tts_local_voice" + ) + + # Get actual voice ID + selected_voice_index = voice_options.index(selected_voice_display) + selected_voice = voice_ids[selected_voice_index] + + with speed_col: + # Speed slider + tts_speed = st.slider( + tr("tts.speed"), + min_value=0.5, + max_value=2.0, + value=saved_speed, + step=0.1, + format="%.1fx", + key="digital_tts_local_speed" + ) + st.caption(tr("tts.speed_label", speed=f"{tts_speed:.1f}")) + + # ================================================================ + # TTS Preview (only local mode) + # ================================================================ + with st.expander(tr("tts.preview_title"), expanded=False): + # Preview text input + preview_text = st.text_input( + tr("tts.preview_text"), + value="大家好,这是一段测试语音。", + placeholder=tr("tts.preview_text_placeholder"), + key="digital_tts_preview_text" + ) + + # Preview button + if st.button(tr("tts.preview_button"), key="digital_preview_tts", use_container_width=True): + with st.spinner(tr("tts.previewing")): + try: + # Build TTS params for local mode + tts_params = { + "text": preview_text, + "inference_mode": "local", + "voice": selected_voice, + "speed": tts_speed + } + + audio_path = run_async(pixelle_video.tts(**tts_params)) + + # Play the audio + if audio_path: + st.success(tr("tts.preview_success")) + if audio_path.startswith('http'): + st.audio(audio_path) + else: + st.audio(audio_path, format="audio/mp3") + + # Show file path + st.caption(f"📁 {audio_path}") + else: + st.error("Failed to generate preview audio") + except Exception as e: + st.error(tr("tts.preview_failed", error=str(e))) + logger.exception(e) + + # Return all style configuration parameters (Simplified version only local TTS) + return { + "tts_inference_mode": "local", + "tts_voice": selected_voice, + "tts_speed": tts_speed + } + diff --git a/web/i18n/locales/en_US.json b/web/i18n/locales/en_US.json index 9d3c786..99c7002 100644 --- a/web/i18n/locales/en_US.json +++ b/web/i18n/locales/en_US.json @@ -15,12 +15,16 @@ "input_mode.custom": "✍️ Custom Content", "mode.generate": "💡 AI Creation", "mode.fixed": "✍️ Custom Script", + "mode.digital": "💻 Product placement mode", + "mode.customize": "🧐 Custom mode", "input.topic": "Topic", "input.topic_placeholder": "AI automatically creates specified number of narrations\nExample: How to build passive income, 如何增加被动收入", "input.topic_help": "Enter a topic, AI will generate content based on it", "input.text": "Text Input", "input.text_help_generate": "Enter topic or theme (AI will create narrations)", "input.text_help_fixed": "Enter complete narration script (used directly without modification)", + "input.text_help_digital": "Enter product description. If a product description is provided, you do not need to enter the product name.", + "input.text_help_audio": "Enter prompts/descriptions, describing the desired visual content in as much detail as possible.", "split.mode_label": "Split Strategy", "split.mode_help": "Choose how to split the text into video segments", "split.mode_paragraph": "📄 By Paragraph (\\n\\n)", @@ -160,6 +164,7 @@ "progress.step_video": "Creating video segment", "progress.concatenating": "Concatenating video...", "progress.finalizing": "Finalizing...", + "progress.generation": "Video is being synthesized...", "progress.completed": "✅ Completed", "error.input_required": "❌ Please provide topic or content", "error.api_key_required": "❌ Please enter API Key", @@ -223,6 +228,20 @@ "tts.voice.en_US_DavisNeural": "en-US-DavisNeural", "tts.voice.en_GB_SoniaNeural": "en-GB-SoniaNeural", "tts.voice.en_GB_RyanNeural": "en-GB-RyanNeural", + "tts.voice.ko-KR-InJoonNeural": "KR Male-Friendly(InJoon)", + "tts.voice.ko-KR-SunHiNeural": "KR Female-Friendly(SunHi)", + "tts.voice.fr-FR-EloiseNeural": "FR Female-Friendly(Eloise)", + "tts.voice.fr-FR-HenriNeural": "FR Male-Friendly(Henri)", + "tts.voice.pt-PT-DuarteNeural": "PT Male-Friendly(Duarte)", + "tts.voice.pt-PT-RaquelNeural": "PT Female-Friendly(Raquel)", + "tts.voice.de-DE-AmalaNeural": "DE Female-Friendly(Amala)", + "tts.voice.de-DE-ConradNeural": "DE Male-Friendly(Conrad)", + "tts.voice.ru-RU-DmitryNeural": "RU Male-Friendly(Dmitry)", + "tts.voice.ru-RU-SvetlanaNeural": "RU Female-Friendly(Svetlana)", + "tts.voice.tr-TR-AhmetNeural": "TR Male-Friendly(Ahmet)", + "tts.voice.tr-TR-EmelNeural": "TR Female-Friendly(Emel)", + "tts.voice.es-ES-AlvaroNeural": "ES Male-Friendly(Alvaro)", + "tts.voice.es-ES-ElviraNeural": "ES Female-Friendly(Elvira)", "tts.selector": "Workflow Selection", "tts.what": "Converts narration text to natural human-like speech (some workflows support reference audio for voice cloning)", "tts.how": "Place the exported tts_xxx.json workflow file(API format) into the workflows/selfhost/ folder (for local ComfyUI) or the workflows/runninghub/ folder (for cloud)", @@ -354,6 +373,10 @@ "pipeline.quick_create.description": "Input an idea, AI completes the entire video for you", "pipeline.custom_media.name": "Custom Media", "pipeline.custom_media.description": "Use your own photos/videos, AI adds narration and voiceover", + "pipeline.digital_human.name": "Digital Human Broadcast", + "pipeline.digital_human.description": "Use a piece of text, two images, and an audio clip, and AI will generate a digital human video for you", + "pipeline.audio-visual.name": "Image-to-Video", + "pipeline.audio-visual.description": "Enter an image and a prompt, and AI will instantly generate a video", "asset_based.section.assets": "📦 Asset Upload", "asset_based.section.video_info": "📝 Video Information", "asset_based.section.source": "⚙️ Service Configuration", @@ -391,6 +414,46 @@ "asset_based.progress.generating_script": "📝 Generating video script...", "asset_based.progress.script_complete": "✅ Script generation complete", "asset_based.progress.concat_complete": "✅ Video concatenation complete", + "digital_human.section.character_assets": "😊 Character Image Upload", + "digital_human.assets.character_what": "Upload an image for the digital human character", + "digital_human.assets.character_warning": "Please upload a character image", + "digital_human.assets.select_mode": "Please select a mode and fill in the corresponding information", + "digital_human.assets.how": "Supported formats: JPG/PNG/WebP; clear and relevant images are recommended", + "digital_human.assets.upload": "Upload Asset", + "digital_human.assets.upload_help": "Only single image upload is supported", + "digital_human.assets.character_sucess": "Character image uploaded successfully", + "digital_human.assets.preview": "📷 Asset Preview", + "digital_human.assets.character_empty_hint": "💡 Please upload a digital human character image", + "digital_human.section.select_mode": "💫 Select Generation Mode", + "digital_human.assets.mode_what": "Select the required digital human generation mode", + "digital_human.assets.select_how": "Supports smart product promotion mode and custom narration mode", + "digital_human.input.topic_placeholder": "Narration script\nExample: Fragrance leaves lasting memories. If you're looking for your own personalized fragrance, why not try TALIA?", + "digital_human.input.content_placeholder": "Used directly, no rewriting\nExample:\nFragrance leaves lasting memories. If you're looking for your own personalized fragrance, try TALIA. It is exquisite and profound, enhancing your charm. Create wonderful moments today with TALIA.", + "digital_human.assets.goods_sucess": "Product image uploaded successfully", + "digital_human.assets.goods_empty_hint": "💡 Please upload a product image", + "digital_human.section.goods_info": "🗃️ Product Information", + "digital_human.goods_title": "AI-created narration", + "digital_human.goods_title_placeholder": "Example: lipstick, coffee machine, robot vacuum, etc.", + "digital_human.goods_title_help": "The product name. If provided, AI will generate narration automatically. You don’t need to enter the product description again unless needed—add it below if required.", + "digital_human.input_text": "Narration script", + "digital_human.customize_text": "Custom text", + "digital_human.section.workflow": "⚙️ Workflow Loading", + "digital_human.workflow.what": "Configure the two-step workflow for digital human video generation: step 1 generates collage and script, step 2 generates final video", + "digital_human.workflow.first_step": "Narration & collage workflow loaded successfully", + "digital_human.workflow.second_step": "Digital human composition workflow loaded successfully", + "digital_human.workflow.ready": "Digital human workflow is ready. You can start video generation", + "digital_human.workflow.missing": "Missing required digital human workflow. Please check the configuration", + "digital_tts.what": "Convert narration text to natural human-like speech", + "audio_visual.input.topic_placeholder": "Enter creative prompts (e.g., audio and rhythm, camera movement rules, overall visual style, color, color grading, etc.)", + "audio_visual.assets.upload": "Upload video first frame image", + "audio_visual.assets.upload_help": "Only single image upload is supported", + "audio_visual.assets.character_sucess": "Video asset uploaded successfully", + "audio_visual.assets.preview": "📷 Asset Preview", + "audio_visual.assets.character_empty_hint": "💡 Please upload a first frame image for your video", + "audio_visual.input_text": "Video prompt", + "audio_visual.assets.image_warning": "Upload the first frame image of your video", + "audio_visual.assets.prompt_warning": "Please enter a video prompt", + "faq.expand_to_view": "FAQ", "faq.load_error": "Failed to load FAQ content", "faq.more_help": "Need more help?" diff --git a/web/i18n/locales/zh_CN.json b/web/i18n/locales/zh_CN.json index fe12bb7..e8e1c7f 100644 --- a/web/i18n/locales/zh_CN.json +++ b/web/i18n/locales/zh_CN.json @@ -15,12 +15,16 @@ "input_mode.custom": "✍️ 自定义内容", "mode.generate": "💡 AI 创作", "mode.fixed": "✍️ 自行创作", + "mode.digital" : "💻 带货模式", + "mode.customize" : "🧐 自定义模式", "input.topic": "主题", "input.topic_placeholder": "AI 自动创作指定数量的旁白\n例如:如何增加被动收入、How to build passive income", "input.topic_help": "输入一个主题,AI 将根据主题生成内容", "input.text": "文本输入", "input.text_help_generate": "输入主题或话题(AI 将创作旁白)", "input.text_help_fixed": "输入完整的旁白脚本(直接使用,不做改写)", + "input.text_help_digital" : "输入商品介绍,若提供了商品介绍则不用再输入商品名称", + "input.text_help_audio" : "输入提示词描述,尽可能详细的描绘想要的画面内容细节", "split.mode_label": "分割方式", "split.mode_help": "选择如何将文本分割为视频片段", "split.mode_paragraph": "📄 按段落(\\n\\n)", @@ -159,6 +163,7 @@ "progress.step_compose": "合成画面", "progress.step_video": "创建视频片段", "progress.concatenating": "正在拼接视频...", + "progress.generation": "正在合成视频...", "progress.finalizing": "完成中...", "progress.completed": "✅ 生成完成", "error.input_required": "❌ 请提供主题或内容", @@ -223,6 +228,20 @@ "tts.voice.en_US_DavisNeural": "男声-友好(Davis)", "tts.voice.en_GB_SoniaNeural": "女声-英式(Sonia)", "tts.voice.en_GB_RyanNeural": "男声-英式(Ryan)", + "tts.voice.ko-KR-InJoonNeural": "KR 男声-友好(仁俊)", + "tts.voice.ko-KR-SunHiNeural": "KR 女声-友好(善海)", + "tts.voice.fr-FR-EloiseNeural": "FR 女声-友好(埃洛伊斯)", + "tts.voice.fr-FR-HenriNeural": "FR 男声-友好(亨利)", + "tts.voice.pt-PT-DuarteNeural": "PT 男声-友好(杜阿尔特)", + "tts.voice.pt-PT-RaquelNeural": "PT 女声-友好(雷切尔)", + "tts.voice.de-DE-AmalaNeural": "DE 女声-友好(阿玛拉)", + "tts.voice.de-DE-ConradNeural": "DE 男声-友好(康拉德)", + "tts.voice.ru-RU-DmitryNeural": "RU 男声-友好(德米特里)", + "tts.voice.ru-RU-SvetlanaNeural": "RU 女声-友好(斯韦特兰娜)", + "tts.voice.tr-TR-AhmetNeural": "TR 男声-友好(艾哈迈德)", + "tts.voice.tr-TR-EmelNeural": "TR 女声-友好(埃梅尔)", + "tts.voice.es-ES-AlvaroNeural": "ES 男声-友好(阿尔瓦罗)", + "tts.voice.es-ES-ElviraNeural": "ES 女声-友好(埃尔维拉)", "tts.selector": "工作流选择", "tts.what": "将旁白文本转换为真人般的自然语音(部分工作流支持参考音频克隆声音)", "tts.how": "将导出的 tts_xxx.json 工作流文件(API格式)放入 workflows/selfhost/(本地 ComfyUI)或 workflows/runninghub/(云端)文件夹", @@ -354,6 +373,10 @@ "pipeline.quick_create.description": "输入一个想法,AI帮你完成整条视频", "pipeline.custom_media.name": "自定义素材", "pipeline.custom_media.description": "用你自己的照片/视频,AI帮你配文案和配音", + "pipeline.digital_human.name": "数字人口播", + "pipeline.digital_human.description": "用一段文本、两张图片、一段音频,AI帮你生成一个数字人视频", + "pipeline.audio-visual.name": "图生视频", + "pipeline.audio-visual.description": "输入图片和提示词,AI即刻生成视频", "asset_based.section.assets": "📦 素材上传", "asset_based.section.video_info": "📝 视频信息", "asset_based.section.source": "⚙️ 服务配置", @@ -391,6 +414,46 @@ "asset_based.progress.generating_script": "📝 正在生成视频脚本...", "asset_based.progress.script_complete": "✅ 脚本生成完成", "asset_based.progress.concat_complete": "✅ 视频合成完成", + "digital_human.section.character_assets": "😊 人物形象上传", + "digital_human.assets.character_what": "上传数字人形象的图片", + "digital_human.assets.character_warning": "请上传人物形象图片", + "digital_human.assets.select_mode": "请选择模式并填入对应信息", + "digital_human.assets.how": "支持 JPG/PNG/WebP 等格式图片,建议素材清晰且内容相关", + "digital_human.assets.upload": "上传素材", + "digital_human.assets.upload_help": "仅支持单张图片上传", + "digital_human.assets.character_sucess": "上传形象图片成功", + "digital_human.assets.preview": "📷 素材预览", + "digital_human.assets.character_empty_hint": "💡 请上传一个数字人形象图片素材", + "digital_human.section.select_mode": "💫 选择生成模式", + "digital_human.assets.mode_what": "选择需要的数字人生成模式", + "digital_human.assets.select_how": "支持智能带货模式以及自定义口播形式", + "digital_human.input.topic_placeholder" : "口播文案\n例如:香氛留下回忆。如果您正在寻找属于自己的专属香氛,不妨试试 TALIA、Fragrance leaves lasting memories. If you're looking for your own personalized fragrance, why not try TALIA?", + "digital_human.input.content_placeholder" : "直接使用,不做改写\n例如:\n香氛留下回忆。如果您正在寻找属于自己的专属香氛,不妨试试 TALIA。它精致而深邃,更能提升您的魅力。今天就用 TALIA 创造专属的美好时刻。", + "digital_human.assets.goods_sucess": "上传商品图片成功", + "digital_human.assets.goods_empty_hint": "💡 请上传一个商品图片素材", + "digital_human.section.goods_info": "🗃️ 商品信息", + "digital_human.goods_title": "AI创作旁白", + "digital_human.goods_title_placeholder": "例如:口红、咖啡机、扫地机器人等", + "digital_human.goods_title_help": "商品的名称,若提供商品名称则AI会自动生成商品旁白,无需再次输入商品描述,若有需要请在下方输入商品介绍", + "digital_human.input_text": "口播文案", + "digital_human.customize_text": "自定义文本", + "digital_human.section.workflow": "⚙️ 工作流加载", + "digital_human.workflow.what": "配置数字人视频生成的两步工作流:第一步生成拼图和文案,第二步生成最终视频", + "digital_human.workflow.first_step": "口播搞&拼图工作流成功加载", + "digital_human.workflow.second_step": "数字人合成工作流成功加载", + "digital_human.workflow.ready": "数字人工作流已就绪,可以开始生成视频", + "digital_human.workflow.missing": "缺少必要的数字人工作流,请检查配置", + "digital_tts.what": "将旁白文本转换为真人般的自然语音", + "audio_visual.input.topic_placeholder" : "输入创作提示词(例如:音频与节奏、镜头与运动规则、整体视觉风格、色彩与调色等)", + "audio_visual.assets.upload": "上传视频首帧图像", + "audio_visual.assets.upload_help": "仅支持单张图片上传", + "audio_visual.assets.character_sucess": "上传视频素材成功", + "audio_visual.assets.preview": "📷 素材预览", + "audio_visual.assets.character_empty_hint": "💡 请上传一个想要生成视频的首帧图像", + "audio_visual.input_text": "视频提示词", + "audio_visual.assets.image_warning": "上传视频首帧图片", + "audio_visual.assets.prompt_warning": "请输入视频提示词", + "faq.expand_to_view": "常见问题", "faq.load_error": "无法加载常见问题内容", "faq.more_help": "需要更多帮助?" diff --git a/web/pipelines/__init__.py b/web/pipelines/__init__.py index 1c5efa7..249769f 100644 --- a/web/pipelines/__init__.py +++ b/web/pipelines/__init__.py @@ -26,6 +26,8 @@ from web.pipelines.base import ( # Import all pipeline UI modules to ensure they register themselves from web.pipelines import standard from web.pipelines import asset_based +from web.pipelines import digital_human +from web.pipelines import audio_visual __all__ = [ "PipelineUI", diff --git a/web/pipelines/audio_visual.py b/web/pipelines/audio_visual.py new file mode 100644 index 0000000..4ed7ead --- /dev/null +++ b/web/pipelines/audio_visual.py @@ -0,0 +1,256 @@ +from curses import noecho +from multiprocessing import process +import os +import time +import uuid +from pathlib import Path +from typing import Any + +import streamlit as st +from loguru import logger +import httpx +from web.i18n import tr, get_language +from web.pipelines.base import PipelineUI, register_pipeline_ui +from web.components.content_input import render_bgm_section, render_version_info +from web.utils.async_helpers import run_async +from pixelle_video.config import config_manager +from pixelle_video.utils.os_util import create_task_output_dir + +class AudioVisualPipelineUI(PipelineUI): + """ + UI for the Audio-visual synchronization Video Generation Pipeline. + Generates videos from user-provided assets (images&text). + """ + name = "voice_synchronization" + icon = "🎥" + + @property + def display_name(self): + return tr("pipeline.audio-visual.name") + + @property + def description(self): + return tr("pipeline.audio-visual.description") + + def render(self, pixelle_video: Any): + # Three-column layout + left_col,right_col = st.columns([1, 1]) + + # ==================================================================== + # Middle Column: Asset Upload + # ==================================================================== + with left_col: + asset_params = self.render_audio_visual_input() + render_version_info() + + with right_col: + video_params = { + **asset_params + } + + self._render_output_preview(pixelle_video, video_params) + + def render_audio_visual_input(self) -> dict: + + # File uploader for multiple files + uploaded_files = st.file_uploader( + tr("audio_visual.assets.upload"), + type=["jpg", "jpeg", "png", "webp"], + accept_multiple_files=True, + help=tr("audio_visual.assets.upload_help"), + key="material_files" + ) + + # Save uploaded files to temp directory with unique session ID + audio_asset_paths = [] + if uploaded_files: + import uuid + session_id = str(uuid.uuid4()).replace('-', '')[:12] + temp_dir = Path(f"temp/assets_{session_id}") + temp_dir.mkdir(parents=True, exist_ok=True) + + for uploaded_file in uploaded_files: + file_path = temp_dir / uploaded_file.name + with open(file_path, "wb") as f: + f.write(uploaded_file.getbuffer()) + audio_asset_paths.append(str(file_path.absolute())) + + st.success(tr("audio_visual.assets.character_sucess")) + + # Preview uploaded assets + with st.expander(tr("audio_visual.assets.preview"), expanded=True): + # Show in a grid (3 columns) + cols = st.columns(3) + for i, (file, path) in enumerate(zip(uploaded_files, audio_asset_paths)): + with cols[i % 3]: + # Check if image + ext = Path(path).suffix.lower() + if ext in [".jpg", ".jpeg", ".png", ".webp"]: + st.image(file, caption=file.name, use_container_width=True) + else: + st.info(tr("audio_visual.assets.character_empty_hint")) + + prompt_text = st.text_area( + tr("audio_visual.input_text"), + placeholder=tr("audio_visual.input.topic_placeholder"), + height=200, + help=tr("input.text_help_audio"), + key="audio_box" + ) + + return { + "audio_assets": audio_asset_paths, + "prompt_text": prompt_text, + } + + def _render_output_preview(self, pixelle_video: Any, video_params: dict): + """Render output preview section""" + with st.container(border=True): + st.markdown(f"**{tr('section.video_generation')}**") + + # Check configuration + if not config_manager.validate(): + st.warning(tr("settings.not_configured")) + + audio_assets = video_params.get("audio_assets", []) + prompt_text = video_params.get("prompt_text", "") + + logger.info(f"video_params: {video_params}") + + if not audio_assets: + st.info(tr("audio_visual.assets.image_warning")) + st.button( + tr("btn.generate"), + type="primary", + use_container_width=True, + disabled=True, + key="audio_visual_generate_disabled" + ) + return + + if not prompt_text: + st.info(tr("audio_visual.assets.prompt_warning")) + st.button( + tr("btn.generate"), + type="primary", + use_container_width=True, + disabled=True, + key="audio_visual_generate" + ) + return + + # Generate button + if st.button(tr("btn.generate"), type="primary", use_container_width=True, key="digital_human_generate"): + # Validate + if not config_manager.validate(): + st.error(tr("settings.not_configured")) + st.stop() + + # Show progress + progress_bar = st.progress(0) + status_text = st.empty() + + start_time = time.time() + + try: + # Define async generation function + async def generate_audio_visual_video(): + task_dir, task_id = create_task_output_dir() + kit = await pixelle_video._get_or_create_comfykit() + + import json + from pathlib import Path + + status_text.text(tr("progress.generation")) + progress_bar.progress(65) + image_path = audio_assets[0] + prompt = prompt_text + + workflow_path = Path("workflows/runninghub/audio_visual.json") + if not workflow_path.exists(): + raise Exception(f"The workflow file does not exist: {workflow_path}") + with open(workflow_path, 'r', encoding='utf-8') as f: + workflow_config = json.load(f) + workflow_params = { + "image": image_path, + "prompt": prompt + } + if workflow_config.get("source") == "runninghub" and "workflow_id" in workflow_config: + workflow_input = workflow_config["workflow_id"] + else: + workflow_input = str(workflow_path) + video_result = await kit.execute(workflow_input, workflow_params) + + generated_video_url = None + if hasattr(video_result, 'videos') and video_result.videos: + generated_video_url = video_result.videos[0] + elif hasattr(video_result, 'outputs') and video_result.outputs: + for node_id, node_output in video_result.outputs.items(): + if isinstance(node_output, dict) and 'videos' in node_output: + videos = node_output['videos'] + if videos and len(videos) > 0: + generated_video_url = videos[0] + break + if not generated_video_url: + raise Exception("The workflow did not return a video. Please check the workflow configuration.") + + final_video_path = os.path.join(task_dir, "final.mp4") + timeout = httpx.Timeout(300.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(generated_video_url) + response.raise_for_status() + with open(final_video_path, 'wb') as f: + f.write(response.content) + progress_bar.progress(100) + status_text.text(tr("status.success")) + return final_video_path + + # Execute async generation + final_video_path = run_async(generate_audio_visual_video()) + + total_time = time.time() - start_time + progress_bar.progress(100) + status_text.text(tr("status.success")) + + # Display result + st.success(tr("status.video_generated", path=final_video_path)) + + st.markdown("---") + + # Video info + if os.path.exists(final_video_path): + file_size_mb = os.path.getsize(final_video_path) / (1024 * 1024) + + info_text = ( + f"⏱️ {tr('info.generation_time')} {total_time:.1f}s " + f"📦 {file_size_mb:.2f}MB" + ) + st.caption(info_text) + + st.markdown("---") + + # Video preview + st.video(final_video_path) + + # Download button + with open(final_video_path, "rb") as video_file: + video_bytes = video_file.read() + video_filename = os.path.basename(final_video_path) + st.download_button( + label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video", + data=video_bytes, + file_name=video_filename, + mime="video/mp4", + use_container_width=True + ) + else: + st.error(tr("status.video_not_found", path=final_video_path)) + + except Exception as e: + status_text.text("") + progress_bar.empty() + st.error(tr("status.error", error=str(e))) + logger.exception(e) + st.stop() + +register_pipeline_ui(AudioVisualPipelineUI) diff --git a/web/pipelines/digital_human.py b/web/pipelines/digital_human.py new file mode 100644 index 0000000..83d4b7d --- /dev/null +++ b/web/pipelines/digital_human.py @@ -0,0 +1,537 @@ +import os +import time +import uuid +from pathlib import Path +from typing import Any + +import streamlit as st +from loguru import logger +import httpx +from web.i18n import tr, get_language +from web.pipelines.base import PipelineUI, register_pipeline_ui +from web.components.content_input import render_bgm_section, render_version_info +from web.components.digital_tts_config import render_style_config +from web.utils.async_helpers import run_async +from pixelle_video.config import config_manager +from pixelle_video.utils.os_util import create_task_output_dir + +class DigitalHumanPipelineUI(PipelineUI): + """ + UI for the Digital_Human Video Generation Pipeline. + Generates videos from user-provided assets (images&videos&audio). + """ + name = "digital_human" + icon = "🤖" + + @property + def display_name(self): + return tr("pipeline.digital_human.name") + + @property + def description(self): + return tr("pipeline.digital_human.description") + + def render(self, pixelle_video: Any): + # Three-column layout + left_col, middle_col, right_col = st.columns([1, 1, 1]) + + # ==================================================================== + # Left Column: Asset Upload + # ==================================================================== + with left_col: + asset_params = self.render_digital_human_input() + style_params = render_style_config(pixelle_video) + # bgm_params = render_bgm_section(key_prefix="asset_") + render_version_info() + + # ==================================================================== + # Middle Column: Video Configuration + # ==================================================================== + with middle_col: + # Style configuration () + mode_params = self.render_digital_human_mode(asset_params["character_assets"]) + + # # ==================================================================== + # # Right Column: Output Preview + # # ==================================================================== + with right_col: + # Combine all parameters + video_params = { + **mode_params, + **asset_params, + **style_params + } + + self._render_output_preview(pixelle_video, video_params) + + def render_digital_human_input(self) -> dict: + """Render digital human character image upload section""" + with st.container(border=True): + st.markdown(f"**{tr('digital_human.section.character_assets')}**") + + with st.expander(tr("help.feature_description"), expanded=False): + st.markdown(f"**{tr('help.what')}**") + st.markdown(tr("digital_human.assets.character_what")) + st.markdown(f"**{tr('help.how')}**") + st.markdown(tr("digital_human.assets.how")) + + # File uploader for multiple files + uploaded_files = st.file_uploader( + tr("digital_human.assets.upload"), + type=["jpg", "jpeg", "png", "webp"], + accept_multiple_files=True, + help=tr("digital_human.assets.upload_help"), + key="character_files" + ) + + # Save uploaded files to temp directory with unique session ID + character_asset_paths = [] + if uploaded_files: + import uuid + session_id = str(uuid.uuid4()).replace('-', '')[:12] + temp_dir = Path(f"temp/assets_{session_id}") + temp_dir.mkdir(parents=True, exist_ok=True) + + for uploaded_file in uploaded_files: + file_path = temp_dir / uploaded_file.name + with open(file_path, "wb") as f: + f.write(uploaded_file.getbuffer()) + character_asset_paths.append(str(file_path.absolute())) + + st.success(tr("digital_human.assets.character_sucess")) + + # Preview uploaded assets + with st.expander(tr("digital_human.assets.preview"), expanded=True): + # Show in a grid (3 columns) + cols = st.columns(3) + for i, (file, path) in enumerate(zip(uploaded_files, character_asset_paths)): + with cols[i % 3]: + # Check if image + ext = Path(path).suffix.lower() + if ext in [".jpg", ".jpeg", ".png", ".webp"]: + st.image(file, caption=file.name, use_container_width=True) + else: + st.info(tr("digital_human.assets.character_empty_hint")) + + return {"character_assets": character_asset_paths} + + def render_digital_human_mode(self, character_asset_paths: list) -> dict: + with st.container(border=True): + st.markdown(f"**{tr('digital_human.section.select_mode')}**") + + with st.expander(tr("help.feature_description"), expanded=False): + st.markdown(f"**{tr('help.what')}**") + st.markdown(tr("digital_human.assets.mode_what")) + st.markdown(f"**{tr('help.how')}**") + st.markdown(tr("digital_human.assets.select_how")) + + mode = st.radio( + "Processing Mode", + ["digital", "customize"], + horizontal=True, + format_func=lambda x: tr(f"mode.{x}"), + label_visibility="collapsed", + key="mode_selection" + ) + + # Text input (unified for both modes) + text_placeholder = tr("digital_human.input.topic_placeholder") if mode == "digital" else tr("digital_human.input.content_placeholder") + text_height = 120 if mode == "digital" else 200 + text_help = tr("input.text_help_digital") if mode == "digital" else tr("input.text_help_fixed") + + if mode == "digital": + # File uploader for multiple files + uploaded_files = st.file_uploader( + tr("digital_human.assets.upload"), + type=["jpg", "jpeg", "png", "webp"], + accept_multiple_files=True, + help=tr("digital_human.assets.upload_help"), + key="digital_files" + ) + + # Save uploaded files to temp directory with unique session ID + goods_asset_paths = [] + if uploaded_files: + import uuid + session_id = str(uuid.uuid4()).replace('-', '')[:12] + temp_dir = Path(f"temp/assets_{session_id}") + temp_dir.mkdir(parents=True, exist_ok=True) + + for uploaded_file in uploaded_files: + file_path = temp_dir / uploaded_file.name + with open(file_path, "wb") as f: + f.write(uploaded_file.getbuffer()) + goods_asset_paths.append(str(file_path.absolute())) + + st.success(tr("digital_human.assets.goods_sucess")) + + # Preview uploaded assets + with st.expander(tr("digital_human.assets.preview"), expanded=True): + # Show in a grid (3 columns) + cols = st.columns(3) + for i, (file, path) in enumerate(zip(uploaded_files, goods_asset_paths)): + with cols[i % 3]: + # Check if image + ext = Path(path).suffix.lower() + if ext in [".jpg", ".jpeg", ".png", ".webp"]: + st.image(file, caption=file.name, use_container_width=True) + else: + st.info(tr("digital_human.assets.goods_empty_hint")) + # Text input + goods_text = st.text_area( + tr("digital_human.input_text"), + placeholder=text_placeholder, + height=text_height, + help=text_help, + key="digital_box" + ) + + goods_title = st.text_input( + tr("digital_human.goods_title"), + placeholder=tr("digital_human.goods_title_placeholder"), + help=tr("digital_human.goods_title_help"), + key="goods_title" + ) + + return { + "character_assets": character_asset_paths, + "goods_title": goods_title, + "goods_assets": goods_asset_paths, + "goods_text": goods_text, + "mode": mode + } + + else: + goods_text = st.text_area( + tr("digital_human.customize_text"), + placeholder=text_placeholder, + height=text_height, + help=text_help, + key="customize_box" + ) + + return { + "character_assets": character_asset_paths, + "goods_text": goods_text, + "mode": mode + } + + def _render_output_preview(self, pixelle_video: Any, video_params: dict): + """Render output preview section""" + with st.container(border=True): + st.markdown(f"**{tr('section.video_generation')}**") + + # Check configuration + if not config_manager.validate(): + st.warning(tr("settings.not_configured")) + + # Get input data + character_assets = video_params.get("character_assets", []) + goods_assets = video_params.get("goods_assets", []) + goods_title = video_params.get("goods_title", "") + goods_text = video_params.get("goods_text", "") + mode = video_params.get("mode") + tts_voice = video_params.get("tts_voice", "zh-CN-YunjianNeural") + tts_speed = video_params.get("tts_speed", 1.2) + + logger.info(f"🔧 The obtained TTS parameters:") + logger.info(f" - tts_voice: {tts_voice}") + logger.info(f" - tts_speed: {tts_speed}") + logger.info(f" - video_params中的tts_voice: {video_params.get('tts_voice', 'NOT_FOUND')}") + logger.info(f" - video_params: {video_params}") + + # Validation + if not character_assets: + st.info(tr("digital_human.assets.character_warning")) + st.button( + tr("btn.generate"), + type="primary", + use_container_width=True, + disabled=True, + key="digital_human_generate_disabled" + ) + return + + if not goods_text and not goods_title: + st.info(tr("digital_human.assets.select_mode")) + st.button( + tr("btn.generate"), + type="primary", + use_container_width=True, + disabled=True, + key="digital_human_generate" + ) + return + + # Generate button + if st.button(tr("btn.generate"), type="primary", use_container_width=True, key="digital_human_generate"): + # Validate + if not config_manager.validate(): + st.error(tr("settings.not_configured")) + st.stop() + + # Show progress + progress_bar = st.progress(0) + status_text = st.empty() + + start_time = time.time() + + try: + # Define async generation function + async def generate_digital_human_video(): + task_dir, task_id = create_task_output_dir() + kit = await pixelle_video._get_or_create_comfykit() + + import json + from pathlib import Path + + if mode == "customize": + status_text.text(tr("progress.step_audio")) + progress_bar.progress(25) + generated_image_path = character_assets[0] + generated_text = goods_text + + # TTS + audio_path = os.path.join(task_dir, "narration.mp3") + await pixelle_video.tts( + text=generated_text, + output_path=audio_path, + inference_mode="local", + voice=tts_voice, + speed=tts_speed + ) + progress_bar.progress(65) + status_text.text(tr("progress.concatenating")) + + # Directly call the second workflow + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + if not second_workflow_path.exists(): + raise Exception(f"The second step workflow file does not exist:{second_workflow_path}") + with open(second_workflow_path, 'r', encoding='utf-8') as f: + second_workflow_config = json.load(f) + second_workflow_params = { + "videoimage": generated_image_path, + "audio": audio_path + } + if second_workflow_config.get("source") == "runninghub" and "workflow_id" in second_workflow_config: + workflow_input = second_workflow_config["workflow_id"] + else: + workflow_input = str(second_workflow_path) + second_result = await kit.execute(workflow_input, second_workflow_params) + # Video Link Extraction + generated_video_url = None + if hasattr(second_result, 'videos') and second_result.videos: + generated_video_url = second_result.videos[0] + elif hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + if isinstance(node_output, dict) and 'videos' in node_output: + videos = node_output['videos'] + if videos and len(videos) > 0: + generated_video_url = videos[0] + break + if not generated_video_url: + raise Exception("The second step of the workflow did not return a video. Please check the workflow configuration.") + + final_video_path = os.path.join(task_dir, "final.mp4") + timeout = httpx.Timeout(300.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(generated_video_url) + response.raise_for_status() + with open(final_video_path, 'wb') as f: + f.write(response.content) + progress_bar.progress(100) + status_text.text(tr("status.success")) + return final_video_path + + else: + #Initialization and parameter preparation + task_dir, task_id = create_task_output_dir() + logger.info(f"[Initialization] Task Directory: {task_dir}") + + first_workflow_path = Path("workflows/runninghub/digital_image.json") + third_workflow_path = Path("workflows/runninghub/digital_customize.json") + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + assert first_workflow_path.exists(), "The first_workflow file does not exist." + assert third_workflow_path.exists(), "The third_workflow file does not exist." + assert second_workflow_path.exists(), "The second_workflow file does not exist." + + if goods_text and goods_text.strip(): + workflow_path = third_workflow_path + workflow_params = {"firstimage": character_assets[0], "secondimage": goods_assets[0]} + + status_text.text(tr("progress.step_image")) + kit = await pixelle_video._get_or_create_comfykit() + workflow_config = json.load(open(workflow_path, 'r', encoding='utf8')) + workflow_input = workflow_config.get("workflow_id", str(workflow_path)) + combine_image = await kit.execute(workflow_input, workflow_params) + if combine_image.status != "completed": + raise Exception(f"workflow execution failed: {combine_image.msg}") + generated_image_url = getattr(combine_image, "images", [None])[0] + status_text.text(tr("progress.step_audio")) + audio_path = os.path.join(task_dir, "narration.mp3") + await pixelle_video.tts( + text=goods_text, + output_path=audio_path, + inference_mode="local", + voice=tts_voice, + speed=tts_speed + ) + progress_bar.progress(65) + status_text.text(tr("progress.progress.concatenating")) + + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + if not second_workflow_path.exists(): + raise Exception(f"The second step workflow file does not exist:{second_workflow_path}") + with open(second_workflow_path, 'r', encoding='utf-8') as f: + second_workflow_config = json.load(f) + second_workflow_params = { + "videoimage": generated_image_url, + "audio": audio_path + } + if second_workflow_config.get("source") == "runninghub" and "workflow_id" in second_workflow_config: + workflow_input = second_workflow_config["workflow_id"] + else: + workflow_input = str(second_workflow_path) + second_result = await kit.execute(workflow_input, second_workflow_params) + # Video Link Extraction + generated_video_url = None + if hasattr(second_result, 'videos') and second_result.videos: + generated_video_url = second_result.videos[0] + elif hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + if isinstance(node_output, dict) and 'videos' in node_output: + videos = node_output['videos'] + if videos and len(videos) > 0: + generated_video_url = videos[0] + break + if not generated_video_url: + raise Exception("The second step of the workflow did not return a video. Please check the workflow configuration.") + + final_video_path = os.path.join(task_dir, "final.mp4") + timeout = httpx.Timeout(300.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(generated_video_url) + response.raise_for_status() + with open(final_video_path, 'wb') as f: + f.write(response.content) + progress_bar.progress(100) + status_text.text(tr("status.success")) + return final_video_path + + else: + workflow_path = first_workflow_path + workflow_params = {"firstimage": character_assets[0], "secondimage": goods_assets[0], "goodstype": goods_title} + + status_text.text(tr("progress.step_image")) + kit = await pixelle_video._get_or_create_comfykit() + workflow_config = json.load(open(workflow_path, 'r', encoding='utf8')) + workflow_input = workflow_config.get("workflow_id", str(workflow_path)) + synthesis_result = await kit.execute(workflow_input, workflow_params) + if synthesis_result.status != "completed": + raise Exception(f"workflow execution failed: {synthesis_result.msg}") + generated_image_url = getattr(synthesis_result, "images", [None])[0] + generated_text = getattr(synthesis_result, "texts", [None])[0] + + status_text.text(tr("progress.step_audio")) + audio_path = os.path.join(task_dir, "narration.mp3") + await pixelle_video.tts( + text=generated_text, + output_path=audio_path, + inference_mode="local", + voice=tts_voice, + speed=tts_speed + ) + progress_bar.progress(65) + status_text.text(tr("progress.concatenating")) + + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + if not second_workflow_path.exists(): + raise Exception(f"The second step workflow file does not exist:{second_workflow_path}") + with open(second_workflow_path, 'r', encoding='utf-8') as f: + second_workflow_config = json.load(f) + second_workflow_params = { + "videoimage": generated_image_url, + "audio": audio_path + } + if second_workflow_config.get("source") == "runninghub" and "workflow_id" in second_workflow_config: + workflow_input = second_workflow_config["workflow_id"] + else: + workflow_input = str(second_workflow_path) + second_result = await kit.execute(workflow_input, second_workflow_params) + # Video Link Extraction + generated_video_url = None + if hasattr(second_result, 'videos') and second_result.videos: + generated_video_url = second_result.videos[0] + elif hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + if isinstance(node_output, dict) and 'videos' in node_output: + videos = node_output['videos'] + if videos and len(videos) > 0: + generated_video_url = videos[0] + break + if not generated_video_url: + raise Exception("The second step of the workflow did not return a video. Please check the workflow configuration.") + + final_video_path = os.path.join(task_dir, "final.mp4") + timeout = httpx.Timeout(300.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(generated_video_url) + response.raise_for_status() + with open(final_video_path, 'wb') as f: + f.write(response.content) + progress_bar.progress(100) + status_text.text(tr("status.success")) + return final_video_path + + # Execute async generation + final_video_path = run_async(generate_digital_human_video()) + + total_time = time.time() - start_time + progress_bar.progress(100) + status_text.text(tr("status.success")) + + # Display result + st.success(tr("status.video_generated", path=final_video_path)) + + st.markdown("---") + + # Video info + if os.path.exists(final_video_path): + file_size_mb = os.path.getsize(final_video_path) / (1024 * 1024) + + info_text = ( + f"⏱️ {tr('info.generation_time')} {total_time:.1f}s " + f"📦 {file_size_mb:.2f}MB" + ) + st.caption(info_text) + + st.markdown("---") + + # Video preview + st.video(final_video_path) + + # Download button + with open(final_video_path, "rb") as video_file: + video_bytes = video_file.read() + video_filename = os.path.basename(final_video_path) + st.download_button( + label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video", + data=video_bytes, + file_name=video_filename, + mime="video/mp4", + use_container_width=True + ) + else: + st.error(tr("status.video_not_found", path=final_video_path)) + + except Exception as e: + status_text.text("") + progress_bar.empty() + st.error(tr("status.error", error=str(e))) + logger.exception(e) + st.stop() + + +# Register self +register_pipeline_ui(DigitalHumanPipelineUI) + diff --git a/workflows/runninghub/audio_visual.json b/workflows/runninghub/audio_visual.json new file mode 100644 index 0000000..dfc05fa --- /dev/null +++ b/workflows/runninghub/audio_visual.json @@ -0,0 +1,4 @@ +{ + "source": "runninghub", + "workflow_id": "2011258580393009153" +} \ No newline at end of file diff --git a/workflows/runninghub/digital_combination.json b/workflows/runninghub/digital_combination.json new file mode 100644 index 0000000..2ea1ce2 --- /dev/null +++ b/workflows/runninghub/digital_combination.json @@ -0,0 +1,4 @@ +{ + "source": "runninghub", + "workflow_id": "2003717471859294210" +} \ No newline at end of file diff --git a/workflows/runninghub/digital_customize.json b/workflows/runninghub/digital_customize.json new file mode 100644 index 0000000..adea389 --- /dev/null +++ b/workflows/runninghub/digital_customize.json @@ -0,0 +1,4 @@ +{ + "source": "runninghub", + "workflow_id": "2010608838151507970" +} \ No newline at end of file diff --git a/workflows/runninghub/digital_image.json b/workflows/runninghub/digital_image.json new file mode 100644 index 0000000..b04f127 --- /dev/null +++ b/workflows/runninghub/digital_image.json @@ -0,0 +1,4 @@ +{ + "source": "runninghub", + "workflow_id": "2004120336125861890" +} \ No newline at end of file