From 6e7df83c6aae8e04f8b02ce7f79bd897b124fece Mon Sep 17 00:00:00 2001 From: 1152450298 <1152450298@qq.com> Date: Wed, 14 Jan 2026 11:27:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=BB=E5=88=86=E6=94=AF?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E6=94=B9=E6=95=B0=E5=AD=97=E4=BA=BA=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=93=BE=E8=B7=AF=E3=80=81=E6=96=B0=E5=A2=9E=E9=9F=B3?= =?UTF-8?q?=E7=94=BB=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/digital_tts_config.py | 237 ++++++ web/i18n/locales/en_US.json | 420 +++++----- web/i18n/locales/zh_CN.json | 62 ++ web/pipelines/__init__.py | 2 + web/pipelines/audio_visual.py | 256 ++++++ web/pipelines/digital_human.py | 742 ++++++++++++++++++ workflows/runninghub/audio_visual.json | 4 + workflows/runninghub/digital_combination.json | 4 + workflows/runninghub/digital_customize.json | 4 + workflows/runninghub/digital_image.json | 4 + 10 files changed, 1556 insertions(+), 179 deletions(-) create mode 100644 web/components/digital_tts_config.py create mode 100644 web/pipelines/audio_visual.py create mode 100644 web/pipelines/digital_human.py create mode 100644 workflows/runninghub/audio_visual.json create mode 100644 workflows/runninghub/digital_combination.json create mode 100644 workflows/runninghub/digital_customize.json create mode 100644 workflows/runninghub/digital_image.json diff --git a/web/components/digital_tts_config.py b/web/components/digital_tts_config.py new file mode 100644 index 0000000..c662390 --- /dev/null +++ b/web/components/digital_tts_config.py @@ -0,0 +1,237 @@ +# 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"] + + # 只使用local模式,移除inference_mode选择 + tts_mode = "local" + + # ================================================================ + # Local Mode UI (简化版本) + # ================================================================ + # 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 (只支持local模式) + # ================================================================ + 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) + + # ==================================================================== + # Storyboard Template Section + # ==================================================================== + + def get_template_preview_path(template_path: str, language: str = "zh_CN") -> str: + """ + Get the preview image path for a template based on language. + + Args: + template_path: Template path like "1080x1920/image_default.html" + language: Language code, either "zh_CN" or "en" + + Returns: + Path to preview image in docs/images/ + """ + # Extract size and template name from path + # e.g., "1080x1920/image_default.html" -> size="1080x1920", name="image_default" + path_parts = template_path.split('/') + if len(path_parts) >= 2: + size = path_parts[0] # e.g., "1080x1920" + template_file = path_parts[1] # e.g., "image_default.html" + template_name = template_file.replace('.html', '') # e.g., "image_default" + + # Build preview image path + # Format: docs/images/{size}/{template_name}.jpg or {template_name}_en.jpg + # Chinese uses Chinese preview, all other languages use English preview for better i18n + suffix = "" if language == "zh_CN" else "_en" + + # Try different image extensions + for ext in ['.jpg', '.png']: + preview_path = f"docs/images/{size}/{template_name}{suffix}{ext}" + if os.path.exists(preview_path): + return preview_path + + # Fallback: try without language suffix (for templates with only one version) + for ext in ['.jpg', '.png']: + preview_path = f"docs/images/{size}/{template_name}{ext}" + if os.path.exists(preview_path): + return preview_path + + # If no preview found, return empty string + return "" + + # ==================================================================== + # Workflow Selection Section (Auto-configured for Digital Human) + # ==================================================================== + with st.container(border=True): + st.markdown(f"**{tr('digital_human.section.workflow')}**") + + with st.expander(tr("help.feature_description"), expanded=False): + st.markdown(f"**{tr('help.what')}**") + st.markdown(tr("digital_human.workflow.what")) + + # Get available workflows + all_workflows = pixelle_video.media.list_workflows() + + # Auto-select digital human workflows (check file existence directly) + + # First workflow: digital_image (generates collage and copy) + first_workflow = "runninghub/digital_image.json" + first_workflow_path = "workflows/runninghub/digital_image.json" + first_workflow_found = os.path.exists(first_workflow_path) + + # Second workflow: digital_combination (generates final video) + second_workflow = "runninghub/digital_combination.json" + second_workflow_path = "workflows/runninghub/digital_combination.json" + second_workflow_found = os.path.exists(second_workflow_path) + + # First workflow status + if first_workflow_found: + st.success(f"✅ {tr('digital_human.workflow.first_step')}") + else: + st.error(f"❌ {tr('digital_human.workflow.first_step')} {tr('digital_human.workflow.not_found')}") + + # Second workflow status + if second_workflow_found: + st.success(f"✅ {tr('digital_human.workflow.second_step')}") + else: + st.error(f"❌ {tr('digital_human.workflow.second_step')} {tr('digital_human.workflow.not_found')}") + + # Show workflow info + if first_workflow_found and second_workflow_found: + st.info(f"🎯 {tr('digital_human.workflow.ready')}") + else: + st.warning(f"⚠️ {tr('digital_human.workflow.missing')}") + + # Return all style configuration parameters (简化版本,只支持local TTS) + return { + "tts_inference_mode": "local", + "tts_voice": selected_voice, + "tts_speed": tts_speed, + "first_workflow": first_workflow, + "second_workflow": second_workflow + } + diff --git a/web/i18n/locales/en_US.json b/web/i18n/locales/en_US.json index 9d3c786..92392c5 100644 --- a/web/i18n/locales/en_US.json +++ b/web/i18n/locales/en_US.json @@ -1,12 +1,12 @@ { - "language_name": "English", + "language_name": "English (US)", "t": { - "app.title": "⚡ Pixelle-Video - AI Auto Short Video Engine", + "app.title": "⚡ Pixelle-Video - AI Fully Automatic Short Video Engine", "app.subtitle": "Powered by Pixelle.AI", "section.content_input": "📝 Video Script", "section.bgm": "🎵 Background Music", - "section.tts": "🎤 Voiceover", - "section.image": "🎨 Image Generation", + "section.tts": "🎤 Voiceover Synthesis", + "section.image": "🎨 Illustration Generation", "section.video": "🎬 Video Generation", "section.media": "🎨 Media Generation", "section.template": "📐 Storyboard Template", @@ -14,44 +14,48 @@ "input_mode.topic": "💡 Topic", "input_mode.custom": "✍️ Custom Content", "mode.generate": "💡 AI Creation", - "mode.fixed": "✍️ Custom Script", + "mode.fixed": "✍️ Manual Creation", + "mode.digital": "💻 E-commerce 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.topic_placeholder": "AI automatically creates a specified number of narrations\nE.g.: How to build passive income, 如何增加被动收入", + "input.topic_help": "Enter a topic, AI will generate content accordingly", "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)", - "split.mode_label": "Split Strategy", - "split.mode_help": "Choose how to split the text into video segments", + "input.text_help_generate": "Enter a topic or idea (AI will create the script)", + "input.text_help_fixed": "Enter a complete narration script (used as is, no rewrite)", + "input.text_help_digital": "Enter product description; if provided, product name is not required", + "input.text_help_audio": "Enter a prompt, describing in detail the visual details you want", + "split.mode_label": "Split Method", + "split.mode_help": "Choose how to split text into video segments", "split.mode_paragraph": "📄 By Paragraph (\\n\\n)", "split.mode_line": "📝 By Line (\\n)", - "split.mode_sentence": "✂️ By Sentence (。.!?)", + "split.mode_sentence": "✂️ By Period (.!?)", "input.content": "Content", - "input.content_placeholder": "Used directly without modification (split by strategy below)\nExample:\nHello everyone, today I'll share three study tips.\n\nThe first tip is focus training, meditate for 10 minutes daily.\n\nThe second tip is active recall, review immediately after learning.", + "input.content_placeholder": "Used directly, no rewrite (split by the method below)\nE.g.:\nHello everyone, here are three study tips.\n\nFirst: Focus training, 10 minutes meditation daily.\n\nSecond: Active recall, repeat immediately after learning.", "input.content_help": "Provide your own content for video generation", "input.title": "Title (Optional)", - "input.title_placeholder": "Video title (auto-generated if empty)", - "input.title_help": "Optional: Custom title for the video", + "input.title_placeholder": "Video title (leave blank for auto-generation)", + "input.title_help": "Optional: Custom video title", "voice.title": "🎤 Voice Selection", - "voice.male_professional": "🎤 Male-Professional", - "voice.male_young": "🎙️ Male-Young", - "voice.female_gentle": "🎵 Female-Gentle", - "voice.female_energetic": "🎶 Female-Energetic", - "voice.preview": "▶ Preview Voice", + "voice.male_professional": "🎤 Male - Professional", + "voice.male_young": "🎙️ Male - Young", + "voice.female_gentle": "🎵 Female - Gentle", + "voice.female_energetic": "🎶 Female - Energetic", + "voice.preview": "▶ Voice Preview", "voice.previewing": "Generating voice preview...", "voice.preview_failed": "Preview failed: {error}", "style.workflow": "Workflow Selection", - "style.workflow_what": "Determines how each frame's illustration is generated and its effect (e.g., using FLUX, SD models)", - "style.workflow_how": "Place the exported image_xxx.json workflow file(API format) into the workflows/selfhost/ folder (for local ComfyUI) or the workflows/runninghub/ folder (for cloud)", - "style.video_workflow_what": "Determines how each frame's video clip is generated and its effect (e.g., using different video generation models)", - "style.video_workflow_how": "Place the exported video_xxx.json workflow file(API format) into the workflows/selfhost/ folder (for local ComfyUI) or the workflows/runninghub/ folder (for cloud)", - "style.image_size_info": "Image Size: {width}x{height} (auto-determined by template)", - "style.video_size_info": "Video Size: {width}x{height} (auto-determined by template)", + "style.workflow_what": "Determines how each illustration in the video is generated (e.g. FLUX, SD model)", + "style.workflow_how": "Place exported image_xxx.json workflow files (API format) in workflows/selfhost/ (local ComfyUI) or workflows/runninghub/ (cloud)", + "style.video_workflow_what": "Determines how each video segment is generated (e.g. using different video generation models)", + "style.video_workflow_how": "Place exported video_xxx.json workflow files (API format) in workflows/selfhost/ (local ComfyUI) or workflows/runninghub/ (cloud)", + "style.image_size_info": "Illustration size: {width}x{height} (set automatically by template)", + "style.video_size_info": "Video size: {width}x{height} (set automatically by template)", "style.prompt_prefix": "Prompt Prefix", - "style.prompt_prefix_what": "Automatically added before all image prompts to control the illustration style uniformly (e.g., cartoon, realistic)", - "style.prompt_prefix_how": "Enter style description in the input box below. To save permanently, edit the config.yaml file", - "style.prompt_prefix_placeholder": "Enter style prefix (leave empty for config default)", - "style.prompt_prefix_help": "This text will be automatically added before all image generation prompts. To permanently change, edit config.yaml", + "style.prompt_prefix_what": "Automatically added to all image prompts for unified illustration style (e.g., cartoon, photo-realistic, etc.)", + "style.prompt_prefix_how": "Enter style description below. To save permanently, edit config.yaml file", + "style.prompt_prefix_placeholder": "Enter style prefix (leave blank for config default)", + "style.prompt_prefix_help": "This will be added to all image prompts. To change permanently, edit config.yaml", "style.custom": "Custom", "style.description": "Style Description", "style.description_placeholder": "Describe the illustration style you want (any language)...", @@ -59,7 +63,7 @@ "style.video_preview_title": "Preview Video", "style.test_prompt": "Test Prompt", "style.test_video_prompt": "Test Video Prompt", - "style.test_prompt_help": "Enter test prompt to preview style effect", + "style.test_prompt_help": "Enter a test prompt to preview style effect", "style.preview": "🖼️ Generate Preview", "style.video_preview": "🎬 Generate Video Preview", "style.previewing": "Generating style preview...", @@ -70,25 +74,25 @@ "style.preview_failed": "Preview failed: {error}", "style.preview_failed_general": "Failed to generate preview image", "style.final_prompt_label": "Final Prompt", - "style.generated_prompt": "Generated prompt: {prompt}", + "style.generated_prompt": "Generated Prompt: {prompt}", "template.selector": "Template Selection", "template.select": "Select Template", "template.select_help": "Select template and video size", - "template.video_size_info": "Final Video Size: {width} × {height}", - "template.separator_selected": "Please select a specific template, not the group header", + "template.video_size_info": "Final video size: {width} × {height}", + "template.separator_selected": "Please select a specific template, not group header", "template.default": "Default", "template.modern": "Modern", "template.neon": "Neon", - "template.what": "Controls the visual layout and design style of each frame (title, text, image arrangement)", - "template.how": "Place .html template files in templates/SIZE/ directories (e.g., templates/1080x1920/). Templates are automatically grouped by size. Custom CSS styles are supported.\n\n**Template Naming Convention**\n\n- `static_*.html` → Static style templates (no AI-generated media)\n- `image_*.html` → Image generation templates (AI-generated images)\n- `video_*.html` → Video generation templates (AI-generated videos)\n\n**Note**\n\nAt least one of the following browsers must be installed on your computer for proper operation:\n1. Google Chrome (Windows, macOS)\n2. Chromium Browser (Linux)\n3. Microsoft Edge", + "template.what": "Controls each video frame’s visual layout and design style (title, text, image arrangement, etc.)", + "template.how": "Place .html template files in templates/Size/ directory (e.g. templates/1080x1920/), and the system will auto-group by size. Custom CSS supported.\n\n**Template Naming Rules**\n\n- `static_*.html` → Static style (no AI media gen)\n- `image_*.html` → Illustration gen (AI images)\n- `video_*.html` → Video gen (AI videos)\n\n**Note**\n\nYou must install at least one of the following browsers:\n1. Google Chrome (Windows/MacOS)\n2. Chromium (Linux)\n3. Microsoft Edge", "template.size_info": "Template Size", - "template.type_selector": "Template Type", + "template.type_selector": "Storyboard Type", "template.type.static": "📄 Static Style", - "template.type.image": "🖼️ Generate Images", - "template.type.video": "🎬 Generate Videos", - "template.type.static_hint": "Uses template's built-in styles, no AI-generated media required. You can customize background images and other parameters in the template.", - "template.type.image_hint": "AI automatically generates illustrations matching the narration content. Image size is determined by the template.", - "template.type.video_hint": "AI automatically generates video clips matching the narration content. Video size is determined by the template.", + "template.type.image": "🖼️ Illustration Generation", + "template.type.video": "🎬 Video Generation", + "template.type.static_hint": "Uses built-in template style, does not require AI media generation. Background images etc. can be customized.", + "template.type.image_hint": "AI auto-generates illustrations to match the script; size is set by template.", + "template.type.video_hint": "AI auto-generates video clips to match the script; size is set by template.", "orientation.portrait": "Portrait", "orientation.landscape": "Landscape", "orientation.square": "Square", @@ -99,7 +103,7 @@ "template.preview_param_width": "Width", "template.preview_param_height": "Height", "template.preview_default_title": "AI Changes Content Creation", - "template.preview_default_text": "Artificial intelligence is transforming the way Pixelle.AI creates content, making it easy for everyone to produce professional-grade videos.", + "template.preview_default_text": "Pixelle.AI is changing content creation with AI, letting everyone easily create professional videos.", "template.preview_button": "🖼️ Generate Preview", "template.preview_generating": "Generating template preview...", "template.preview_success": "✅ Preview generated successfully!", @@ -107,26 +111,26 @@ "template.preview_image_help": "Supports local path or URL", "template.preview_caption": "Template Preview: {template}", "template.custom_parameters": "Custom Parameters", - "template.gallery_view": "Template Gallery", - "template.select_button": "Check", - "template.selected": "Checked", + "template.gallery_view": "Template Library", + "template.select_button": "Select", + "template.selected": "Selected", "template.selected_template": "Current Template", "template.no_templates_with_preview": "⚠️ No templates available for this type", - "image.not_required": "Current template does not require image generation", - "image.not_required_hint": "The selected template is text-only and does not need images. Benefits: ⚡ Faster generation 💰 Lower cost", + "image.not_required": "The current template does not require image generation", + "image.not_required_hint": "You selected a text-only template. This will: ⚡ Speed up generation 💰 Lower generation cost", "video.title": "🎬 Video Settings", - "video.frames": "Scenes", - "video.frames_help": "More scenes = longer video", - "video.frames_label": "Scenes: {n}", - "video.frames_fixed_mode_hint": "💡 Fixed mode: scene count is determined by actual script segments", + "video.frames": "Storyboards Count", + "video.frames_help": "More storyboards = longer video", + "video.frames_label": "Storyboards: {n}", + "video.frames_fixed_mode_hint": "💡 Fixed mode: storyboard count determined by script paragraphs", "bgm.selector": "Music Selection", - "bgm.none": "🔇 No BGM", + "bgm.none": "🔇 No Background Music", "bgm.volume": "Volume", - "bgm.volume_help": "Adjust background music volume (0.0 = muted, 1.0 = original volume)", + "bgm.volume_help": "Adjust BGM volume (0.0 = mute, 1.0 = original)", "bgm.preview": "▶ Preview Music", "bgm.preview_failed": "❌ Music file not found: {file}", - "bgm.what": "Adds background music to your video, making it more atmospheric and professional", - "bgm.how": "Place audio files (MP3/WAV/FLAC, etc.) in the bgm/ folder for automatic detection", + "bgm.what": "Add BGM to make the video more atmospheric and professional", + "bgm.how": "Place audio files (MP3/WAV/FLAC etc.) in the bgm/ folder for auto detection", "btn.generate": "🎬 Generate Video", "btn.save_config": "💾 Save Configuration", "btn.reset_config": "🔄 Reset to Default", @@ -138,9 +142,9 @@ "status.error": "❌ Generation failed: {error}", "status.video_generated": "✅ Video generated: {path}", "status.video_not_found": "Video file not found: {path}", - "status.config_saved": "✅ Configuration saved", - "status.config_reset": "✅ Configuration reset to defaults", - "status.llm_config_incomplete": "⚠️ LLM configuration incomplete, please fill in API Key, Base URL and Model", + "status.config_saved": "✅ Config saved", + "status.config_reset": "✅ Config reset to default", + "status.llm_config_incomplete": "⚠️ LLM configuration incomplete. Please fill in API Key, Base URL, and Model", "status.save_failed": "Save failed", "status.connection_success": "✅ Connection successful", "status.connection_failed": "❌ Connection failed", @@ -149,34 +153,34 @@ "progress.splitting_script": "Splitting script...", "progress.generating_image_prompts": "Generating image prompts...", "progress.generating_video_prompts": "Generating video prompts...", - "progress.preparing_frames": "Preparing frames...", - "progress.frame": "Frame {current}/{total}", - "progress.frame_step": "Frame {current}/{total} - Step {step}/4: {action}", - "progress.processing_frame": "Processing frame {current}/{total}...", - "progress.step_audio": "Generating audio", - "progress.step_image": "Generating image", - "progress.step_media": "Generating media", - "progress.step_compose": "Composing frame", - "progress.step_video": "Creating video segment", + "progress.preparing_frames": "Preparing storyboards...", + "progress.frame": "Storyboard {current}/{total}", + "progress.frame_step": "Storyboard {current}/{total} - Step {step}/4: {action}", + "progress.processing_frame": "Processing storyboard {current}/{total}...", + "progress.step_audio": "Generate audio", + "progress.step_image": "Generate illustration", + "progress.step_media": "Generate media", + "progress.step_compose": "Compose scene", + "progress.step_video": "Create video segment", "progress.concatenating": "Concatenating video...", "progress.finalizing": "Finalizing...", - "progress.completed": "✅ Completed", - "error.input_required": "❌ Please provide topic or content", + "progress.completed": "✅ Generation completed", + "error.input_required": "❌ Please input a topic or content", "error.api_key_required": "❌ Please enter API Key", - "error.missing_field": "Please enter {field}", + "error.missing_field": "Please fill in {field}", "info.duration": "Duration", "info.file_size": "File Size", - "info.frames": "Scenes", - "info.scenes_unit": " scenes", + "info.frames": "Storyboards", + "info.scenes_unit": "Storyboard", "info.resolution": "Resolution", - "info.video_information": "📊 Video Information", - "info.no_video_yet": "Video preview will appear here after generation", + "info.video_information": "📊 Video Info", + "info.no_video_yet": "Preview will show here after video is generated", "info.generation_time": "Generation Time", "settings.title": "⚙️ System Configuration (Required)", - "settings.not_configured": "⚠️ Please complete system configuration before generating videos", + "settings.not_configured": "⚠️ Please complete system config before generating video", "settings.llm.title": "🤖 Large Language Model", "settings.llm.quick_select": "Quick Select", - "settings.llm.quick_select_help": "Choose a preset LLM or custom configuration", + "settings.llm.quick_select_help": "Choose preset LLM or custom config", "settings.llm.get_api_key": "Get API Key", "settings.llm.api_key": "API Key", "settings.llm.api_key_help": "Enter your API Key", @@ -184,17 +188,17 @@ "settings.llm.base_url_help": "API service address", "settings.llm.model": "Model", "settings.llm.model_help": "Model name", - "settings.comfyui.title": "🔧 ComfyUI Configuration", - "settings.comfyui.local_title": "Local/Self-hosted ComfyUI", + "settings.comfyui.title": "🔧 ComfyUI Config", + "settings.comfyui.local_title": "Local/Self-built ComfyUI", "settings.comfyui.cloud_title": "RunningHub Cloud", - "settings.comfyui.comfyui_url": "ComfyUI Server URL", + "settings.comfyui.comfyui_url": "ComfyUI Server Address", "settings.comfyui.comfyui_url_help": "Local or remote ComfyUI server address", "settings.comfyui.comfyui_api_key": "ComfyUI API Key", - "settings.comfyui.comfyui_api_key_help": "Optional, get from https://platform.comfy.org/profile/api-keys", + "settings.comfyui.comfyui_api_key_help": "Optional. Visit https://platform.comfy.org/profile/api-keys to get", "settings.comfyui.runninghub_api_key": "RunningHub API Key", - "settings.comfyui.runninghub_api_key_help": "Visit https://runninghub.ai to register and get API Key", + "settings.comfyui.runninghub_api_key_help": "Register and get at https://runninghub.ai", "settings.comfyui.runninghub_hint": "No local ComfyUI? Use RunningHub Cloud:", - "settings.comfyui.runninghub_get_api_key": "Get RunningHub API Key", + "settings.comfyui.runninghub_get_api_key": "Click here to get RunningHub API Key", "settings.comfyui.runninghub_concurrent_limit": "Concurrent Limit", "settings.comfyui.runninghub_concurrent_limit_help": "RunningHub concurrent execution limit (1-10), default is 1 for regular members, adjust based on your membership level", "settings.comfyui.runninghub_instance_type": "Machine Spec", @@ -204,48 +208,62 @@ "tts.inference_mode": "Synthesis Mode", "tts.mode.local": "Local Synthesis", "tts.mode.comfyui": "ComfyUI Synthesis", - "tts.mode.local_hint": "💡 Using Edge TTS, no configuration required, ready to use", - "tts.mode.comfyui_hint": "⚙️ Using ComfyUI workflows, flexible and powerful", + "tts.mode.local_hint": "💡 Uses Edge TTS, no config needed, plug-and-play (ensure internet access)", + "tts.mode.comfyui_hint": "⚙️ Uses ComfyUI workflow, flexible and powerful", "tts.voice_selector": "Voice Selection", "tts.speed": "Speed", "tts.speed_label": "{speed}x", - "tts.voice.zh_CN_XiaoxiaoNeural": "zh-CN-XiaoxiaoNeural", - "tts.voice.zh_CN_XiaoyiNeural": "zh-CN-XiaoyiNeural", - "tts.voice.zh_CN_YunjianNeural": "zh-CN-YunjianNeural", - "tts.voice.zh_CN_YunxiNeural": "zh-CN-YunxiNeural", - "tts.voice.zh_CN_YunyangNeural": "zh-CN-YunyangNeural", - "tts.voice.zh_CN_YunyeNeural": "zh-CN-YunyeNeural", - "tts.voice.zh_CN_YunfengNeural": "zh-CN-YunfengNeural", - "tts.voice.zh_CN_liaoning_XiaobeiNeural": "zh-CN-liaoning-XiaobeiNeural", - "tts.voice.en_US_AriaNeural": "en-US-AriaNeural", - "tts.voice.en_US_JennyNeural": "en-US-JennyNeural", - "tts.voice.en_US_GuyNeural": "en-US-GuyNeural", - "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.zh_CN_XiaoxiaoNeural": "Female - Gentle (Xiaoxiao)", + "tts.voice.zh_CN_XiaoyiNeural": "Female - Sweet (Xiaoyi)", + "tts.voice.zh_CN_YunjianNeural": "Male - Professional (Yunjian)", + "tts.voice.zh_CN_YunxiNeural": "Male - Deep (Yunxi)", + "tts.voice.zh_CN_YunyangNeural": "Male - News (Yunyang)", + "tts.voice.zh_CN_YunyeNeural": "Male - Natural (Yunye)", + "tts.voice.zh_CN_YunfengNeural": "Male - Calm (Yunfeng)", + "tts.voice.zh_CN_liaoning_XiaobeiNeural": "Female - Northeast (Xiaobei)", + "tts.voice.en_US_AriaNeural": "Female - Natural (Aria)", + "tts.voice.en_US_JennyNeural": "Female - Warm (Jenny)", + "tts.voice.en_US_GuyNeural": "Male - Standard (Guy)", + "tts.voice.en_US_DavisNeural": "Male - Friendly (Davis)", + "tts.voice.en_GB_SoniaNeural": "Female - British (Sonia)", + "tts.voice.en_GB_RyanNeural": "Male - British (Ryan)", + "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)", + "tts.what": "Convert narration text to life-like human speech (some workflows support reference audio for voice cloning)", + "tts.how": "Place exported tts_xxx.json workflow files (API format) in workflows/selfhost/ (local ComfyUI) or workflows/runninghub/ (cloud)", "tts.ref_audio": "Reference Audio", - "tts.ref_audio_help": "Upload audio file for voice cloning (only supported by some workflows)", + "tts.ref_audio_help": "Upload audio for voice cloning (only supported in some workflows)", "tts.preview_title": "Preview TTS", "tts.preview_text": "Preview Text", - "tts.preview_text_placeholder": "Enter text to preview...", + "tts.preview_text_placeholder": "Enter text to preview audio...", "tts.preview_button": "🔊 Generate Preview", "tts.previewing": "Generating TTS preview...", "tts.preview_success": "✅ Preview generated successfully!", "tts.preview_failed": "❌ Preview failed: {error}", "welcome.first_time": "🎉 Welcome to Pixelle-Video! Please complete basic configuration", - "welcome.config_hint": "💡 First-time setup requires API Key configuration, you can modify it in advanced settings later", - "wizard.llm_required": "🤖 Large Language Model Configuration (Required)", - "wizard.image_optional": "🎨 Image Generation Configuration (Optional)", - "wizard.image_hint": "💡 If not configured, default template will be used (no AI image generation)", + "welcome.config_hint": "💡 On first use, you need to configure the API Key; later it can be changed in Advanced Settings", + "wizard.llm_required": "🤖 LLM Configuration (Required)", + "wizard.image_optional": "🎨 Image Generation Config (Optional)", + "wizard.image_hint": "💡 If image generation is not configured, default template (no AI images) will be used", "wizard.configure_image": "Configure Image Generation (Recommended)", "label.required": "(Required)", "label.optional": "(Optional)", "help.feature_description": "💡 Feature Description", - "help.what": "Purpose", - "help.how": "Customization", + "help.what": "Function", + "help.how": "How to customize", "language.select": "🌐 Language", "version.title": "📦 Version Info", "version.current": "Current Version", @@ -256,44 +274,44 @@ "history.failed_count": "Failed", "history.total_duration": "Total Duration", "history.total_size": "Total Size", - "history.filter_status": "Filter Status", + "history.filter_status": "Status Filter", "history.status_all": "All", "history.status_completed": "Completed", "history.status_failed": "Failed", "history.status_running": "Running", "history.status_pending": "Pending", "history.sort_by": "Sort By", - "history.sort_created_at": "Created Time", - "history.sort_completed_at": "Completed Time", + "history.sort_created_at": "Creation Time", + "history.sort_completed_at": "Completion Time", "history.sort_title": "Title", "history.sort_duration": "Duration", "history.sort_order_desc": "Descending", "history.sort_order_asc": "Ascending", - "history.page_size": "Page Size", + "history.page_size": "Items Per Page", "history.no_tasks": "No tasks yet", "history.task_card.title": "Title", - "history.task_card.created_at": "Created", + "history.task_card.created_at": "Created At", "history.task_card.duration": "Duration", - "history.task_card.frames": "Frames", - "history.task_card.view_detail": "View Detail", - "history.task_card.duplicate": "Duplicate", + "history.task_card.frames": "Storyboards", + "history.task_card.view_detail": "View Details", + "history.task_card.duplicate": "Duplicate Params", "history.task_card.delete": "Delete", - "history.task_card.download": "Download", + "history.task_card.download": "Download Video", "history.task_card.status_completed": "✅ Completed", "history.task_card.status_failed": "❌ Failed", "history.task_card.status_running": "⏳ Running", "history.task_card.status_pending": "⏸️ Pending", - "history.detail.modal_title": "Task Detail", + "history.detail.modal_title": "Task Details", "history.detail.task_id": "Task ID", - "history.detail.input_params": "Input Parameters", + "history.detail.input_params": "Input Params", "history.detail.text": "Text", "history.detail.mode": "Mode", - "history.detail.n_scenes": "Scenes", + "history.detail.n_scenes": "Storyboards", "history.detail.tts_mode": "TTS Mode", "history.detail.voice": "Voice", "history.detail.storyboard": "Storyboard", - "history.detail.frame_index": "Frame {index}", - "history.detail.frame": "Frame", + "history.detail.frame_index": "Storyboard {index}", + "history.detail.frame": "Storyboard", "history.detail.download_video": "Download Video", "history.detail.narration": "Narration", "history.detail.image_prompt": "Image Prompt", @@ -301,96 +319,140 @@ "history.detail.image_path": "Image", "history.detail.video_segment_path": "Video Segment", "history.detail.close": "Close", - "history.action.duplicate_success": "✅ Parameters duplicated, redirecting...", - "history.action.duplicate_failed": "❌ Duplication failed: {error}", - "history.action.delete_confirm": "Confirm deletion? This action cannot be undone!", + "history.action.duplicate_success": "✅ Params copied, redirecting to home...", + "history.action.duplicate_failed": "❌ Copy failed: {error}", + "history.action.delete_confirm": "Are you sure to delete this task? This action cannot be undone!", "history.action.delete_success": "✅ Task deleted", "history.action.delete_failed": "❌ Deletion failed: {error}", "history.page_info": "Page {page} / {total_pages}", "batch.mode_label": "🔢 Batch Generation Mode", - "batch.mode_help": "Generate multiple videos, one topic per line", - "batch.section_title": "Batch Topics Input", + "batch.mode_help": "Generate multiple videos in batch, one topic per line", + "batch.section_title": "Batch Topic Input", "batch.section_generation": "📦 Batch Video Generation", "batch.rules_title": "Batch Generation Rules", - "batch.rule_1": "Automatically use 'AI Generate Content' mode", + "batch.rule_1": "Automatically uses 'AI Content Generation' mode", "batch.rule_2": "Enter one topic per line", - "batch.rule_3": "All videos use the same configuration (TTS, template, workflow, etc.)", + "batch.rule_3": "All videos use the same settings (TTS, template, workflow, etc.)", "batch.topics_label": "Batch Topics (one per line)", - "batch.topics_placeholder": "Why develop a reading habit\nHow to manage time efficiently\n5 secrets to healthy living\nBenefits of waking up early\nHow to overcome procrastination\nTechniques to stay focused\nEmotional management methods\nTips to improve memory\nBuilding good relationships\nWealth management basics", - "batch.topics_help": "One video topic per line, AI will generate content based on the topic", - "batch.count_success": "✅ Detected {count} topics", - "batch.count_error": "❌ Batch size exceeds limit (max 100), current: {count}", + "batch.topics_placeholder": "Why develop a reading habit\nHow to manage time efficiently\n5 secrets for healthy living\nBenefits of getting up early\nHow to overcome procrastination\nTips to stay focused\nMethods for emotion management\nTips to boost memory\nBuilding good relationships\nBasics of wealth management", + "batch.topics_help": "One topic per line. AI will auto-generate scripts by topic", + "batch.count_success": "✅ Found {count} topics", + "batch.count_error": "❌ Batch count exceeds limit (max 100), current: {count}", "batch.preview_title": "📋 Preview Topic List", - "batch.title_prefix_label": "Title Prefix (optional)", - "batch.title_prefix_placeholder": "e.g., Knowledge Sharing", - "batch.title_prefix_help": "Final title format: {prefix} - {topic}, e.g., Knowledge Sharing - Why develop a reading habit", - "batch.n_scenes_label": "Scenes (unified for all videos)", - "batch.n_scenes_help": "Number of scenes per video, same setting for all videos", - "batch.n_scenes_caption": "Scenes: {n}", - "batch.config_info": "Other configurations: TTS voice, video template, image workflow, etc. will use the settings from the right panel, unified for all videos", - "batch.no_topics": "⚠️ Please enter batch topics on the left (one per line)", - "batch.prepare_info": "📊 Ready to generate {count} videos (using same configuration)", - "batch.estimated_time": "⏱️ Estimated time: about {minutes} minutes", - "batch.generate_button": "🚀 Batch Generate {count} Videos", - "batch.generate_help": "⚠️ Please keep the page open during batch generation, do not close the browser", - "batch.overall_progress": "Overall Progress", + "batch.title_prefix_label": "Title Prefix (Optional)", + "batch.title_prefix_placeholder": "E.g.: Knowledge Sharing", + "batch.title_prefix_help": "Final title format: {prefix} - {topic}, e.g.: Knowledge Sharing - Why develop a reading habit", + "batch.n_scenes_label": "Storyboards Count (applies to all videos)", + "batch.n_scenes_help": "Number of storyboards per video, same for all", + "batch.n_scenes_caption": "Storyboards: {n}", + "batch.config_info": "Other settings: TTS, video template, image workflow etc. will use sidebar settings, same for all videos", + "batch.no_topics": "⚠️ Please input batch topics on the left, one per line", + "batch.prepare_info": "📊 Preparing to generate {count} videos (with unified config)", + "batch.estimated_time": "⏱️ Estimated total time: about {minutes} min", + "batch.generate_button": "🚀 Generate {count} videos in batch", + "batch.generate_help": "⚠️ Please keep the page open during batch generation. Do not close the browser.", + "batch.overall_progress": "Total Progress", "batch.current_task": "Current Task", - "batch.completed": "Batch generation completed!", + "batch.completed": "Batch Generation Completed!", "batch.results_title": "📊 Batch Generation Results", "batch.total": "Total", "batch.success": "Success", "batch.failed": "Failed", "batch.total_time": "Total Time", - "batch.minutes": "m", - "batch.seconds": "s", - "batch.success_message": "✅ Batch generation completed! All videos have been saved to history.", + "batch.minutes": "min", + "batch.seconds": "sec", + "batch.success_message": "✅ Batch generation completed! All videos saved to history.", "batch.view_in_history": "💡 Tip: You can view all generated videos in the '📚 History' page.", "batch.goto_history": "Go to History Page", "batch.failed_list": "❌ Failed Tasks", "batch.task": "Task", - "batch.error": "Error", - "batch.error_detail": "View detailed error stack", - "pipeline.quick_create.name": "Quick Create", - "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", + "batch.error": "Error Message", + "batch.error_detail": "View Detailed Error Stack", + "pipeline.quick_create.name": "Quick Creation", + "pipeline.quick_create.description": "Enter an idea, AI helps you finish the whole video", + "pipeline.custom_media.name": "Custom Asset", + "pipeline.custom_media.description": "Use your own photos/videos, AI adds script and voiceover", + "pipeline.digital_human.name": "Digital Human Generation", + "pipeline.digital_human.description": "Use a paragraph of text, two images, and one audio to generate a digital human video", + "pipeline.audio-visual.name": "Audio-visual Sync", + "pipeline.audio-visual.description": "Enter image and prompt, AI instantly generates synchronized video", "asset_based.section.assets": "📦 Asset Upload", - "asset_based.section.video_info": "📝 Video Information", + "asset_based.section.video_info": "📝 Video Info", "asset_based.section.source": "⚙️ Service Configuration", - "asset_based.assets.what": "Upload your images or video assets, AI will automatically analyze them and generate a video script", - "asset_based.assets.how": "Supports JPG/PNG/GIF/WebP images and MP4/MOV/AVI videos. Each asset should be clear and relevant", - "asset_based.assets.upload": "Upload Assets", - "asset_based.assets.upload_help": "Supports multiple image or video files", + "asset_based.assets.what": "Upload your images/videos, AI will analyze and auto-generate a video script", + "asset_based.assets.how": "Supports JPG/PNG/GIF/WebP images and MP4/MOV/AVI etc. videos. Each asset should be clear and relevant", + "asset_based.assets.upload": "Upload Asset", + "asset_based.assets.upload_help": "Supports uploading multiple images or videos", "asset_based.assets.count": "✅ Uploaded {count} assets", "asset_based.assets.preview": "📷 Asset Preview", "asset_based.assets.empty_hint": "💡 Please upload at least one image or video asset", "asset_based.video_title": "Video Title (Optional)", - "asset_based.video_title_placeholder": "e.g., Pet Store Year-End Sale", - "asset_based.video_title_help": "Main title for the video, leave empty to hide title", + "asset_based.video_title_placeholder": "E.g.: Year End Sale at Pet Store", + "asset_based.video_title_help": "Main video title; leave blank to not show title", "asset_based.intent": "Video Intent", - "asset_based.intent_placeholder": "e.g., Promote our pet store's year-end special offers to attract more customers, use a warm and friendly tone", - "asset_based.intent_help": "Describe the purpose, message, and desired style of this video", - "asset_based.duration": "Target Duration (seconds)", - "asset_based.duration_help": "Expected video duration, AI will adjust based on asset count", - "asset_based.duration_label": "Target Duration: {seconds}s", - "asset_based.source.what": "Select the service provider for image analysis", - "asset_based.source.how": "RunningHub is a cloud service requiring API Key; SelfHost uses local ComfyUI", + "asset_based.intent_placeholder": "E.g.: Promote our pet store's year-end specials, attract more customers, style should be warm and friendly", + "asset_based.intent_help": "Describe the video’s purpose, message, and desired style", + "asset_based.duration": "Target Duration (Seconds)", + "asset_based.duration_help": "Expected video duration. AI will adjust based on number and length of assets", + "asset_based.duration_label": "Target Duration: {seconds} sec", + "asset_based.source.what": "Choose an image analysis service provider", + "asset_based.source.how": "RunningHub is a cloud service requiring API Key; SelfHost is local ComfyUI service", "asset_based.source.select": "Select Service", "asset_based.source.runninghub": "☁️ RunningHub (Cloud)", "asset_based.source.selfhost": "🖥️ SelfHost (Local)", - "asset_based.source.runninghub_hint": "💡 Using RunningHub cloud service for asset analysis", - "asset_based.source.selfhost_hint": "💡 Using local ComfyUI service for asset analysis", + "asset_based.source.runninghub_hint": "💡 Use RunningHub cloud for asset analysis", + "asset_based.source.selfhost_hint": "💡 Use local ComfyUI for asset analysis", "asset_based.source.runninghub_not_configured": "⚠️ RunningHub API Key not configured", - "asset_based.source.selfhost_not_configured": "⚠️ Local ComfyUI URL not configured", - "asset_based.output.no_assets": "💡 Please upload assets on the left first", + "asset_based.source.selfhost_not_configured": "⚠️ Local ComfyUI address not configured", + "asset_based.output.no_assets": "💡 Please upload assets from the left", "asset_based.output.ready": "📦 {count} assets ready, you can start generating", "asset_based.progress.analyzing": "🔍 Analyzing assets...", - "asset_based.progress.analyzing_start": "🔍 Starting to analyze {total} assets...", + "asset_based.progress.analyzing_start": "🔍 Starting analysis of {total} assets...", "asset_based.progress.analyzing_asset": "🔍 Analyzing asset {current}/{total}: {name}", - "asset_based.progress.analyzing_complete": "✅ Asset analysis complete ({count} total)", + "asset_based.progress.analyzing_complete": "✅ Asset analysis completed ({count})", "asset_based.progress.generating_script": "📝 Generating video script...", - "asset_based.progress.script_complete": "✅ Script generation complete", - "asset_based.progress.concat_complete": "✅ Video concatenation complete", + "asset_based.progress.script_complete": "✅ Script generation completed", + "asset_based.progress.concat_complete": "✅ Video concatenation completed", + "digital_human.section.character_assets": "😊 Character Image Upload", + "digital_human.assets.character_what": "Upload digital human character images", + "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": "Supports JPG/PNG/WebP etc. images; assets should be clear and relevant", + "digital_human.assets.upload": "Upload Asset", + "digital_human.assets.upload_help": "Only single image upload 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 character image asset", + "digital_human.section.select_mode": "💫 Select Generation Mode", + "digital_human.assets.mode_what": "Choose the required digital human generation mode", + "digital_human.assets.select_how": "Supports e-commerce and custom voiceover forms", + "digital_human.input.topic_placeholder": "AI auto-creates product narration\nE.g.: Fragrance leaves behind memories.", + "digital_human.input.content_placeholder": "Used directly, no rewrite\nE.g.:\nFragrance leaves behind memories. If you want your own unique fragrance, try TALIA. Delicate and deep, it enhances your charm. Create wonderful moments with TALIA today.", + "digital_human.assets.goods_sucess": "Product image uploaded successfully", + "digital_human.assets.goods_empty_hint": "💡 Please upload a product image asset", + "digital_human.section.goods_info": "🗃️ Product Info", + "digital_human.goods_title": "Product Name", + "digital_human.goods_title_placeholder": "E.g.: Lipstick, Coffee machine, Robot vacuum, etc.", + "digital_human.goods_title_help": "If product name is provided, AI can auto-generate narration. If needed, input product intro below.", + "digital_human.input_text": "Product Description", + "digital_human.customize_text": "Custom Text", + "digital_human.section.workflow": "⚙️ Workflow Load", + "digital_human.workflow.what": "Configure two-step digital human video generation workflow: step 1 generates collage and script, step 2 generates the final video", + "digital_human.workflow.first_step": "Voiceover & collage workflow loaded successfully", + "digital_human.workflow.second_step": "Digital human synthesis workflow loaded successfully", + "digital_human.workflow.ready": "Digital human workflow ready, you can start generating", + "digital_human.workflow.missing": "Missing necessary digital human workflow, please check the configuration", + "digital_tts.what": "Convert narration text into life-like speech", + "audio_visual.input.topic_placeholder": "Enter a creative prompt (e.g.: audio & rhythm, camera & movement rules, overall visual style, color & grading, etc.)", + "audio_visual.assets.upload": "Upload Video Asset", + "audio_visual.assets.upload_help": "Only support single image upload", + "audio_visual.assets.character_sucess": "Video asset uploaded successfully", + "audio_visual.assets.preview": "📷 Asset Preview", + "audio_visual.assets.character_empty_hint": "💡 Please upload an image asset to generate video", + "audio_visual.input_text": "Creative Script", + "audio_visual.assets.image_warning": "Please upload video footage", + "audio_visual.assets.prompt_warning": "Please enter the video prompt message", + "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..0c8f2b6 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)", @@ -223,6 +227,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 +372,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 +413,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" : "AI 自动创作商品旁白\n例如:香氛留下回忆、Fragrance leaves behind memories.", + "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": "商品名称", + "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..cdce380 --- /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("加载图片成功,准备生成视频") + 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"工作流文件不存在: {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("视频生成失败,请检查工作流配置") + + 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..2eaf77e --- /dev/null +++ b/web/pipelines/digital_human.py @@ -0,0 +1,742 @@ +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() + # bgm_params = render_bgm_section(key_prefix="asset_") + render_version_info() + + # ==================================================================== + # Middle Column: Video Configuration + # ==================================================================== + with middle_col: + # Style configuration (TTS, template, workflow, etc.) + style_params = render_style_config(pixelle_video) + + # # ==================================================================== + # # Right Column: Output Preview + # # ==================================================================== + with right_col: + # Combine all parameters + video_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")) + + 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_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" + ) + + goods_text = st.text_area( + tr("digital_human.input_text"), + placeholder=text_placeholder, + height=text_height, + help=text_help, + key="digital_box" + ) + + 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") + first_workflow = video_params.get("first_workflow") + second_workflow = video_params.get("second_workflow") + third_workflow = video_params.get("third_workflow") + tts_voice = video_params.get("tts_voice", "zh-CN-YunjianNeural") + tts_speed = video_params.get("tts_speed", 1.2) + + logger.info(f"🔧 获取到的TTS参数:") + 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 + + # Workflow validation + # if not first_workflow or not second_workflow or not third_workflow: + # st.warning("请选择工作流配置") + # st.button( + # tr("btn.generate"), + # type="primary", + # use_container_width=True, + # disabled=True, + # key="digital_human_generate_disabled_no_workflow" + # ) + # 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("步骤 1/2: 文案转语音 ...") + 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("步骤 2/2: 合成视频 ...") + + # 直接调用第二工作流 + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + if not second_workflow_path.exists(): + raise Exception(f"第二步工作流文件不存在: {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) + # 视频链接提取 + 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("第二步工作流未返回视频,请检查工作流配置") + + 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: + # Create task directory + task_dir, task_id = create_task_output_dir() + logger.info(f"📁 Task directory created: {task_dir}") + + # ============================================================ + # Step 1: Call first workflow (character_image + goods_image + goods_title) + # ============================================================ + status_text.text("步骤 1/3: 调用第一步工作流生成拼图和文案...") + progress_bar.progress(10) + + # Directly load first workflow file + first_workflow_path = Path("workflows/runninghub/digital_image.json") + third_workflow_path = Path("workflows/runninghub/digital_customize.json") + if not first_workflow_path.exists(): + raise Exception(f"第一步工作流文件不存在: {first_workflow_path}") + + if not third_workflow_path.exists(): + raise Exception(f"第一步工作流文件不存在: {third_workflow_path}") + + if goods_text and goods_text.strip(): + logger.info("✏️ 文本输入非空") + workflow_path = third_workflow_path + with open(third_workflow_path, 'r', encoding='utf-8') as f: + workflow_config = json.load(f) + workflow_params = { + "firstimage": character_assets[0], + "secondimage": goods_assets[0] + } + else: + logger.info("🖼️ 文本输入为空,按原逻辑调用第一工作流") + workflow_path = first_workflow_path + with open(first_workflow_path, 'r', encoding='utf-8') as f: + workflow_config = json.load(f) + workflow_params = { + "firstimage": character_assets[0], + "secondimage": goods_assets[0], + "goodstype": goods_title + } + + + logger.info(f"🔧 传递给第一步工作流的参数:") + for key, value in workflow_params.items(): + if isinstance(value, str) and len(value) > 100: + logger.info(f" - {key}: {value[:100]}... (文件路径)") + else: + logger.info(f" - {key}: {value}") + + kit = await pixelle_video._get_or_create_comfykit() + if workflow_config.get("source") == "runninghub" and "workflow_id" in workflow_config: + workflow_input = workflow_config["workflow_id"] + else: + workflow_input = str(workflow_path) + + logger.info(f"执行当前工作流: {workflow_input}") + first_result = await kit.execute(workflow_input, workflow_params) + + if first_result.status != "completed": + raise Exception(f"第一步工作流执行失败: {first_result.msg}") + + # Debug: Log the actual result structure + logger.info(f"🔍 第一步工作流返回结果调试:") + logger.info(f"- result type: {type(first_result)}") + logger.info(f"- result.__dict__: {first_result.__dict__}") + + if hasattr(first_result, 'images'): + logger.info(f"- images: {first_result.images}") + if hasattr(first_result, 'texts'): + logger.info(f"- texts: {first_result.texts}") + if hasattr(first_result, 'videos'): + logger.info(f"- videos: {first_result.videos}") + if hasattr(first_result, 'outputs'): + logger.info(f"- outputs keys: {list(first_result.outputs.keys()) if first_result.outputs else 'None'}") + if first_result.outputs: + for node_id, node_output in first_result.outputs.items(): + logger.info(f" - 节点 {node_id}: {node_output}") + + for attr in dir(first_result): + if not attr.startswith('_'): + try: + value = getattr(first_result, attr) + if not callable(value): + logger.info(f"- {attr}: {value}") + except: + pass + + # Extract results from first workflow + generated_image_url = None + generated_text = None + generated_video_url = None + + if hasattr(first_result, 'videos') and first_result.videos: + generated_video_url = first_result.videos[0] + logger.info(f"✅ 第一步工作流直接生成了视频: {generated_video_url}") + + progress_bar.progress(100) + status_text.text(tr("status.success")) + + # Download video to local + final_video_path = os.path.join(task_dir, "final.mp4") + timeout = httpx.Timeout(300.0) # 简化timeout设置 + 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) + + return final_video_path + + # Extract image - try direct access first + if hasattr(first_result, 'images') and first_result.images: + generated_image_url = first_result.images[0] + logger.info(f"✅ 从 result.images 获取图片: {generated_image_url}") + elif hasattr(first_result, 'outputs') and first_result.outputs: + # Try to find image in outputs + for node_id, node_output in first_result.outputs.items(): + logger.info(f"- 检查节点 {node_id}: {list(node_output.keys()) if isinstance(node_output, dict) else type(node_output)}") + if isinstance(node_output, dict) and 'images' in node_output: + images = node_output['images'] + if images and len(images) > 0: + generated_image_url = images[0] + logger.info(f"✅ 从 outputs[{node_id}].images 获取图片: {generated_image_url}") + break + + # Extract text - try direct access first + if hasattr(first_result, 'texts') and first_result.texts: + generated_text = first_result.texts[0] + logger.info(f"✅ 从 result.texts 获取文本: {generated_text[:50]}...") + elif hasattr(first_result, 'outputs') and first_result.outputs: + # Try to find text in outputs + for node_id, node_output in first_result.outputs.items(): + if isinstance(node_output, dict) and 'text' in node_output: + text_list = node_output['text'] + if text_list and len(text_list) > 0: + generated_text = text_list[0] + logger.info(f"✅ 从 outputs[{node_id}].text 获取文本: {generated_text[:50]}...") + break + + if not generated_image_url: + logger.error("❌ 第一步工作流未返回图片,尝试查找其他输出...") + if hasattr(first_result, 'outputs') and first_result.outputs: + for node_id, node_output in first_result.outputs.items(): + logger.info(f"- 节点 {node_id} 输出: {node_output}") + + if hasattr(first_result, 'outputs') and first_result.outputs: + for node_id, node_output in first_result.outputs.items(): + if isinstance(node_output, dict): + for key in ['images', 'image', 'output_image', 'result_image']: + if key in node_output and node_output[key]: + if isinstance(node_output[key], list) and len(node_output[key]) > 0: + generated_image_url = node_output[key][0] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取图片: {generated_image_url}") + break + elif isinstance(node_output[key], str): + generated_image_url = node_output[key] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取图片: {generated_image_url}") + break + if generated_image_url: + break + + if not generated_image_url: + raise Exception("第一步工作流未返回图片,请检查工作流配置") + + if not generated_text: + logger.warning("⚠️ 第一步工作流未返回文本,尝试查找其他文本输出...") + if hasattr(first_result, 'outputs') and first_result.outputs: + for node_id, node_output in first_result.outputs.items(): + if isinstance(node_output, dict): + for key in ['text', 'texts', 'output_text', 'result_text', 'description', 'caption']: + if key in node_output and node_output[key]: + if isinstance(node_output[key], list) and len(node_output[key]) > 0: + generated_text = node_output[key][0] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取文本: {generated_text[:50]}...") + break + elif isinstance(node_output[key], str): + generated_text = node_output[key] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取文本: {generated_text[:50]}...") + break + if generated_text: + break + + if not generated_text: + generated_text = goods_text + logger.warning(f"⚠️ 使用自定义文本: {generated_text}") + + logger.info(f"✅ 第一步完成: 图片={generated_image_url}, 文本={generated_text[:50]}...") + + # Download generated image to local + generated_image_path = os.path.join(task_dir, "generated_image.png") + timeout = httpx.Timeout(10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(generated_image_url) + response.raise_for_status() + with open(generated_image_path, 'wb') as f: + f.write(response.content) + + progress_bar.progress(40) + status_text.text("步骤 2/3: 将文案转换为语音...") + + # ============================================================ + # Step 2: TTS conversion (text -> 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 + ) + + logger.info(f"✅ 第二步完成: 语音={audio_path}") + progress_bar.progress(70) + status_text.text("步骤 3/3: 调用第二步工作流生成最终视频...") + + # ============================================================ + # Step 3: Call second workflow (generated_image + audio) + # ============================================================ + # Directly load second workflow file + second_workflow_path = Path("workflows/runninghub/digital_combination.json") + if not second_workflow_path.exists(): + raise Exception(f"第二步工作流文件不存在: {second_workflow_path}") + + with open(second_workflow_path, 'r', encoding='utf-8') as f: + second_workflow_config = json.load(f) + + # Build workflow parameters for second workflow + if mode == "digital": + second_workflow_params = { + "videoimage": generated_image_path, + "audio": audio_path, + } + else: + second_workflow_params = { + "videoimage": character_assets[0], + "audio": audio_path + } + + logger.info(f"🔧 传递给第二步工作流的参数:") + for key, value in second_workflow_params.items(): + logger.info(f" - {key}: {value}") + + # Execute second workflow + 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) + + logger.info(f"执行第二步工作流: {workflow_input}") + second_result = await kit.execute(workflow_input, second_workflow_params) + + if second_result.status != "completed": + raise Exception(f"第二步工作流执行失败: {second_result.msg}") + + # Debug: Log the second workflow result structure + logger.info(f"🔍 第二步工作流返回结果调试:") + logger.info(f"- result type: {type(second_result)}") + logger.info(f"- result.__dict__: {second_result.__dict__}") + + if hasattr(second_result, 'videos'): + logger.info(f"- videos: {second_result.videos}") + if hasattr(second_result, 'images'): + logger.info(f"- images: {second_result.images}") + if hasattr(second_result, 'texts'): + logger.info(f"- texts: {second_result.texts}") + if hasattr(second_result, 'outputs'): + logger.info(f"- outputs keys: {list(second_result.outputs.keys()) if second_result.outputs else 'None'}") + if second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + logger.info(f" - 节点 {node_id}: {node_output}") + + for attr in dir(second_result): + if not attr.startswith('_'): + try: + value = getattr(second_result, attr) + if not callable(value): + logger.info(f"- {attr}: {value}") + except: + pass + + # Extract video from second workflow result + generated_video_url = None + + if hasattr(second_result, 'videos') and second_result.videos: + generated_video_url = second_result.videos[0] + logger.info(f"✅ 从 result.videos 获取视频: {generated_video_url}") + elif hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + logger.info(f"- 检查节点 {node_id}: {list(node_output.keys()) if isinstance(node_output, dict) else type(node_output)}") + 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] + logger.info(f"✅ 从 outputs[{node_id}].videos 获取视频: {generated_video_url}") + break + + if not generated_video_url: + logger.error("❌ 第二步工作流未返回视频,尝试查找其他输出...") + if hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + logger.info(f"- 节点 {node_id} 输出: {node_output}") + + if hasattr(second_result, 'outputs') and second_result.outputs: + for node_id, node_output in second_result.outputs.items(): + if isinstance(node_output, dict): + for key in ['videos', 'video', 'output_video', 'result_video', 'mp4']: + if key in node_output and node_output[key]: + if isinstance(node_output[key], list) and len(node_output[key]) > 0: + generated_video_url = node_output[key][0] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取视频: {generated_video_url}") + break + elif isinstance(node_output[key], str): + generated_video_url = node_output[key] + logger.info(f"✅ 从 outputs[{node_id}].{key} 获取视频: {generated_video_url}") + break + if generated_video_url: + break + + if not generated_video_url: + raise Exception("第二步工作流未返回视频,请检查工作流配置") + + logger.info(f"✅ 第三步完成: 视频={generated_video_url}") + + # Download video to local + 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) + + 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