790 lines
33 KiB
Python
790 lines
33 KiB
Python
"""
|
|
ReelForge Web UI
|
|
|
|
A simple web interface for generating book short videos.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import streamlit as st
|
|
from loguru import logger
|
|
|
|
# Import i18n and config manager
|
|
from reelforge.i18n import load_locales, set_language, tr, get_available_languages
|
|
from reelforge.config_manager import ConfigManager
|
|
from reelforge.models.progress import ProgressEvent
|
|
|
|
# Setup page config (must be first)
|
|
st.set_page_config(
|
|
page_title="ReelForge - AI Book Video Generator",
|
|
page_icon="📚",
|
|
layout="wide",
|
|
initial_sidebar_state="collapsed",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Async Helper
|
|
# ============================================================================
|
|
|
|
def run_async(coro):
|
|
"""Run async coroutine in sync context"""
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def safe_rerun():
|
|
"""Safe rerun that works with both old and new Streamlit versions"""
|
|
if hasattr(st, 'rerun'):
|
|
st.rerun()
|
|
else:
|
|
st.experimental_rerun()
|
|
|
|
|
|
# ============================================================================
|
|
# Configuration & i18n Initialization
|
|
# ============================================================================
|
|
|
|
@st.cache_resource
|
|
def get_config_manager():
|
|
"""Get ConfigManager instance (cached)"""
|
|
manager = ConfigManager()
|
|
manager.load_or_create_default()
|
|
return manager
|
|
|
|
|
|
def init_i18n():
|
|
"""Initialize internationalization"""
|
|
# Load locales if not already loaded
|
|
load_locales()
|
|
|
|
# Get language from session state or default to Chinese
|
|
if "language" not in st.session_state:
|
|
st.session_state.language = "zh_CN"
|
|
|
|
# Set current language
|
|
set_language(st.session_state.language)
|
|
|
|
|
|
# ============================================================================
|
|
# Preview Cache Functions
|
|
# ============================================================================
|
|
|
|
def generate_style_preview_cached(
|
|
style_preset_name: str,
|
|
custom_style_description: str
|
|
):
|
|
"""
|
|
Generate and cache visual style preview
|
|
|
|
Args:
|
|
style_preset_name: Preset style name ("minimal", "futuristic", etc.) or empty
|
|
custom_style_description: Custom description or empty
|
|
|
|
Returns:
|
|
Tuple of (image_path, converted_prompt_if_custom)
|
|
"""
|
|
from reelforge.services.final_image_prompt import StylePreset
|
|
|
|
reelforge = get_reelforge()
|
|
|
|
# Convert style preset name to enum
|
|
style_preset = None
|
|
if style_preset_name:
|
|
try:
|
|
style_preset = StylePreset[style_preset_name.upper()]
|
|
except KeyError:
|
|
pass
|
|
|
|
# Generate final prompt using the new service
|
|
final_prompt = run_async(reelforge.generate_final_image_prompt(
|
|
prompt="A beautiful book on a desk",
|
|
style_preset=style_preset,
|
|
custom_style_description=custom_style_description
|
|
))
|
|
|
|
# Generate preview image (small size for speed)
|
|
preview_image_path = run_async(reelforge.image(
|
|
workflow="workflows/t2i_by_local_flux.json",
|
|
prompt=final_prompt,
|
|
width=512,
|
|
height=512
|
|
))
|
|
|
|
# Return converted prompt only for custom mode
|
|
converted_prompt = final_prompt if custom_style_description else None
|
|
|
|
return preview_image_path, converted_prompt
|
|
|
|
|
|
# ============================================================================
|
|
# Initialize ReelForge
|
|
# ============================================================================
|
|
|
|
@st.cache_resource
|
|
def get_reelforge():
|
|
"""Get initialized ReelForge instance (cached)"""
|
|
from reelforge.service import reelforge
|
|
|
|
logger.info("Initializing ReelForge...")
|
|
run_async(reelforge.initialize())
|
|
logger.info("ReelForge initialized")
|
|
|
|
return reelforge
|
|
|
|
|
|
# ============================================================================
|
|
# Session State
|
|
# ============================================================================
|
|
|
|
def init_session_state():
|
|
"""Initialize session state variables"""
|
|
if "book_info" not in st.session_state:
|
|
st.session_state.book_info = None
|
|
if "language" not in st.session_state:
|
|
st.session_state.language = "zh_CN"
|
|
|
|
|
|
# ============================================================================
|
|
# System Configuration (Required)
|
|
# ============================================================================
|
|
|
|
def render_advanced_settings(config_manager: ConfigManager):
|
|
"""Render system configuration (required) with 2-column layout"""
|
|
# Check if system is configured
|
|
is_configured = config_manager.validate()
|
|
|
|
# Expand if not configured, collapse if configured
|
|
with st.expander(tr("settings.title"), expanded=not is_configured):
|
|
# 2-column layout: LLM | Image+Book
|
|
llm_col, image_col = st.columns(2)
|
|
|
|
# ====================================================================
|
|
# Column 1: LLM Settings (Simplified 3-field format)
|
|
# ====================================================================
|
|
with llm_col:
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('settings.llm.title')}**")
|
|
|
|
# Quick preset selection
|
|
from reelforge.llm_presets import get_preset_names, get_preset, find_preset_by_base_url_and_model
|
|
|
|
# Custom at the end
|
|
preset_names = get_preset_names() + ["Custom"]
|
|
|
|
# Get current config
|
|
current_llm = config_manager.get_llm_config()
|
|
|
|
# Auto-detect which preset matches current config
|
|
current_preset = find_preset_by_base_url_and_model(
|
|
current_llm["base_url"],
|
|
current_llm["model"]
|
|
)
|
|
|
|
# Determine default index based on current config
|
|
if current_preset:
|
|
# Current config matches a preset
|
|
default_index = preset_names.index(current_preset)
|
|
else:
|
|
# Current config doesn't match any preset -> Custom
|
|
default_index = len(preset_names) - 1
|
|
|
|
selected_preset = st.selectbox(
|
|
tr("settings.llm.quick_select"),
|
|
options=preset_names,
|
|
index=default_index,
|
|
help=tr("settings.llm.quick_select_help"),
|
|
key="llm_preset_select"
|
|
)
|
|
|
|
# Auto-fill based on selected preset
|
|
if selected_preset != "Custom":
|
|
# Preset selected
|
|
preset_config = get_preset(selected_preset)
|
|
|
|
# If user switched to a different preset (not current one), clear API key
|
|
# If it's the same as current config, keep API key
|
|
if selected_preset == current_preset:
|
|
# Same preset as saved config: keep API key
|
|
default_api_key = current_llm["api_key"]
|
|
else:
|
|
# Different preset: clear API key
|
|
default_api_key = ""
|
|
|
|
default_base_url = preset_config.get("base_url", "")
|
|
default_model = preset_config.get("model", "")
|
|
|
|
# Show API key URL if available
|
|
if preset_config.get("api_key_url"):
|
|
st.markdown(f"🔑 [{tr('settings.llm.get_api_key')}]({preset_config['api_key_url']})")
|
|
else:
|
|
# Custom: clear all fields (let user fill custom config)
|
|
default_api_key = ""
|
|
default_base_url = ""
|
|
default_model = ""
|
|
|
|
st.markdown("---")
|
|
|
|
# API Key (use unique key to force refresh when switching preset)
|
|
llm_api_key = st.text_input(
|
|
f"{tr('settings.llm.api_key')} *",
|
|
value=default_api_key,
|
|
type="password",
|
|
help=tr("settings.llm.api_key_help"),
|
|
key=f"llm_api_key_input_{selected_preset}"
|
|
)
|
|
|
|
# Base URL (use unique key based on preset to force refresh)
|
|
llm_base_url = st.text_input(
|
|
f"{tr('settings.llm.base_url')} *",
|
|
value=default_base_url,
|
|
help=tr("settings.llm.base_url_help"),
|
|
key=f"llm_base_url_input_{selected_preset}"
|
|
)
|
|
|
|
# Model (use unique key based on preset to force refresh)
|
|
llm_model = st.text_input(
|
|
f"{tr('settings.llm.model')} *",
|
|
value=default_model,
|
|
help=tr("settings.llm.model_help"),
|
|
key=f"llm_model_input_{selected_preset}"
|
|
)
|
|
|
|
# ====================================================================
|
|
# Column 2: Image Settings
|
|
# ====================================================================
|
|
with image_col:
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('settings.image.title')}**")
|
|
|
|
# Get current configuration
|
|
comfykit_config = config_manager.config.get("image", {}).get("comfykit", {})
|
|
|
|
# Local/Self-hosted ComfyUI configuration
|
|
st.markdown(f"**{tr('settings.image.local_title')}**")
|
|
comfyui_url = st.text_input(
|
|
tr("settings.image.comfyui_url"),
|
|
value=comfykit_config.get("comfyui_url", "http://127.0.0.1:8188"),
|
|
help=tr("settings.image.comfyui_url_help"),
|
|
key="comfyui_url_input"
|
|
)
|
|
|
|
# Test connection button
|
|
if st.button(tr("btn.test_connection"), key="test_comfyui", use_container_width=True):
|
|
try:
|
|
import requests
|
|
response = requests.get(f"{comfyui_url}/system_stats", timeout=5)
|
|
if response.status_code == 200:
|
|
st.success(tr("status.connection_success"))
|
|
else:
|
|
st.error(tr("status.connection_failed"))
|
|
except Exception as e:
|
|
st.error(f"{tr('status.connection_failed')}: {str(e)}")
|
|
|
|
st.markdown("---")
|
|
|
|
# RunningHub cloud configuration
|
|
st.markdown(f"**{tr('settings.image.cloud_title')}**")
|
|
runninghub_api_key = st.text_input(
|
|
tr("settings.image.runninghub_api_key"),
|
|
value=comfykit_config.get("runninghub_api_key", ""),
|
|
type="password",
|
|
help=tr("settings.image.runninghub_api_key_help"),
|
|
key="runninghub_api_key_input"
|
|
)
|
|
|
|
# ====================================================================
|
|
# Action Buttons (full width at bottom)
|
|
# ====================================================================
|
|
st.markdown("---")
|
|
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
if st.button(tr("btn.save_config"), use_container_width=True, key="save_config_btn"):
|
|
try:
|
|
# Save LLM configuration (simple 3-field format)
|
|
if llm_api_key and llm_base_url and llm_model:
|
|
config_manager.set_llm_config(llm_api_key, llm_base_url, llm_model)
|
|
|
|
# Save Image configuration (both local and cloud can be configured)
|
|
config_manager.config["image"]["default"] = "comfykit"
|
|
image_config = {}
|
|
if comfyui_url:
|
|
image_config["comfyui_url"] = comfyui_url
|
|
if runninghub_api_key:
|
|
image_config["runninghub_api_key"] = runninghub_api_key
|
|
if image_config:
|
|
config_manager.config["image"]["comfykit"] = image_config
|
|
|
|
# Save to file
|
|
config_manager.save()
|
|
|
|
st.success(tr("status.config_saved"))
|
|
safe_rerun()
|
|
except Exception as e:
|
|
st.error(f"{tr('status.save_failed')}: {str(e)}")
|
|
|
|
with col2:
|
|
if st.button(tr("btn.reset_config"), use_container_width=True, key="reset_config_btn"):
|
|
config_manager.config = config_manager._create_default_config()
|
|
config_manager.save()
|
|
st.success(tr("status.config_reset"))
|
|
safe_rerun()
|
|
|
|
|
|
# ============================================================================
|
|
# Language Selector
|
|
# ============================================================================
|
|
|
|
def render_language_selector():
|
|
"""Render language selector at the top"""
|
|
languages = get_available_languages()
|
|
lang_options = [f"{code} - {name}" for code, name in languages.items()]
|
|
|
|
current_lang = st.session_state.get("language", "zh_CN")
|
|
current_index = list(languages.keys()).index(current_lang) if current_lang in languages else 0
|
|
|
|
selected = st.selectbox(
|
|
tr("language.select"),
|
|
options=lang_options,
|
|
index=current_index,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
selected_code = selected.split(" - ")[0]
|
|
if selected_code != current_lang:
|
|
st.session_state.language = selected_code
|
|
set_language(selected_code)
|
|
safe_rerun()
|
|
|
|
|
|
# ============================================================================
|
|
# Main UI
|
|
# ============================================================================
|
|
|
|
def main():
|
|
# Initialize
|
|
init_session_state()
|
|
init_i18n()
|
|
|
|
config_manager = get_config_manager()
|
|
|
|
# Top bar: Title + Language selector
|
|
col1, col2 = st.columns([4, 1])
|
|
with col1:
|
|
st.title(tr("app.title"))
|
|
with col2:
|
|
render_language_selector()
|
|
|
|
# Initialize ReelForge
|
|
reelforge = get_reelforge()
|
|
|
|
# ========================================================================
|
|
# System Configuration (Required)
|
|
# Auto-expands if not configured, collapses if configured
|
|
# ========================================================================
|
|
render_advanced_settings(config_manager)
|
|
|
|
# Three-column layout
|
|
left_col, middle_col, right_col = st.columns([1, 1, 1])
|
|
|
|
# ========================================================================
|
|
# Left Column: Book & Content
|
|
# ========================================================================
|
|
with left_col:
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('section.content_input')}**")
|
|
|
|
# Input mode selection
|
|
input_mode = st.radio(
|
|
"Input Mode",
|
|
[tr("input_mode.book"), tr("input_mode.topic"), tr("input_mode.custom")],
|
|
horizontal=True,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
book_name = None
|
|
topic = None
|
|
content = None
|
|
title = None
|
|
|
|
if input_mode == tr("input_mode.book"):
|
|
book_name = st.text_input(
|
|
tr("input.book_name"),
|
|
placeholder=tr("input.book_name_placeholder"),
|
|
help=tr("input.book_name_help")
|
|
)
|
|
|
|
# Book search button
|
|
if st.button(tr("book.search"), use_container_width=True):
|
|
if book_name:
|
|
with st.spinner(tr("book.searching")):
|
|
try:
|
|
# Search book using BookFetcherService
|
|
book_info = run_async(reelforge.book_fetcher(book_name))
|
|
st.session_state.book_info = book_info
|
|
st.success(tr("book.found"))
|
|
except Exception as e:
|
|
st.error(tr("book.not_found", error=str(e)))
|
|
else:
|
|
st.warning(tr("book.name_required"))
|
|
|
|
# Display book info if available
|
|
if st.session_state.book_info:
|
|
st.markdown("---")
|
|
book_info = st.session_state.book_info
|
|
|
|
# Book cover (if available)
|
|
if hasattr(book_info, 'cover_url') and book_info.cover_url:
|
|
st.image(book_info.cover_url, width=200)
|
|
|
|
# Book details
|
|
st.markdown(f"**{tr('book.title')}:** {book_info.title}")
|
|
if hasattr(book_info, 'author'):
|
|
st.markdown(f"**{tr('book.author')}:** {book_info.author}")
|
|
if hasattr(book_info, 'rating'):
|
|
st.markdown(f"**{tr('book.rating')}:** ⭐ {book_info.rating}")
|
|
if hasattr(book_info, 'summary'):
|
|
with st.expander(tr("book.summary")):
|
|
st.write(book_info.summary)
|
|
|
|
elif input_mode == tr("input_mode.topic"):
|
|
topic = st.text_area(
|
|
tr("input.topic"),
|
|
placeholder=tr("input.topic_placeholder"),
|
|
height=100,
|
|
help=tr("input.topic_help")
|
|
)
|
|
|
|
else: # Custom Content
|
|
content = st.text_area(
|
|
tr("input.content"),
|
|
placeholder=tr("input.content_placeholder"),
|
|
height=200,
|
|
help=tr("input.content_help")
|
|
)
|
|
title = st.text_input(
|
|
tr("input.title"),
|
|
placeholder=tr("input.title_placeholder")
|
|
)
|
|
|
|
# ====================================================================
|
|
# Video Settings (moved from right column)
|
|
# ====================================================================
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('video.title')}**")
|
|
|
|
# Number of frames
|
|
n_frames = st.slider(
|
|
tr("video.frames"),
|
|
min_value=3,
|
|
max_value=30,
|
|
value=5,
|
|
help=tr("video.frames_help"),
|
|
label_visibility="collapsed"
|
|
)
|
|
st.caption(tr("video.frames_label", n=n_frames))
|
|
|
|
st.markdown("---")
|
|
|
|
# Background music
|
|
st.markdown(f"**{tr('bgm.title')}**")
|
|
|
|
# Dynamically scan bgm folder for music files (support common audio formats)
|
|
bgm_folder = Path("bgm")
|
|
bgm_files = []
|
|
if bgm_folder.exists():
|
|
audio_extensions = ["*.mp3", "*.wav", "*.flac", "*.m4a", "*.aac", "*.ogg"]
|
|
for ext in audio_extensions:
|
|
bgm_files.extend([f.name for f in bgm_folder.glob(ext)])
|
|
bgm_files.sort()
|
|
|
|
# Add special "None" option
|
|
bgm_options = [tr("bgm.none")] + bgm_files
|
|
|
|
# Default to "default.mp3" if exists, otherwise first option
|
|
default_index = 0
|
|
if "default.mp3" in bgm_files:
|
|
default_index = bgm_options.index("default.mp3")
|
|
|
|
bgm_choice = st.selectbox(
|
|
"BGM",
|
|
bgm_options,
|
|
index=default_index,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# BGM preview button (only if BGM is not "None")
|
|
if bgm_choice != tr("bgm.none"):
|
|
if st.button(tr("bgm.preview"), key="preview_bgm", use_container_width=True):
|
|
bgm_file_path = f"bgm/{bgm_choice}"
|
|
if os.path.exists(bgm_file_path):
|
|
st.audio(bgm_file_path)
|
|
else:
|
|
st.error(tr("bgm.preview_failed", file=bgm_choice))
|
|
|
|
# Extract filename without extension for bgm_path (service layer expects stem only)
|
|
bgm_path = None if bgm_choice == tr("bgm.none") else Path(bgm_choice).stem
|
|
|
|
# ========================================================================
|
|
# Middle Column: Custom Settings (Voice & Visual Style & Template)
|
|
# ========================================================================
|
|
with middle_col:
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('section.style_settings')}**")
|
|
|
|
# Voice selection
|
|
st.markdown(f"**{tr('voice.title')}**")
|
|
voice_id = st.selectbox(
|
|
"Voice",
|
|
[
|
|
"zh-CN-YunjianNeural", # 男声-专业
|
|
"zh-CN-YunxiNeural", # 男声-年轻
|
|
"zh-CN-XiaoxiaoNeural", # 女声-温柔
|
|
"zh-CN-XiaoyiNeural", # 女声-活力
|
|
],
|
|
format_func=lambda x: {
|
|
"zh-CN-YunjianNeural": tr("voice.male_professional"),
|
|
"zh-CN-YunxiNeural": tr("voice.male_young"),
|
|
"zh-CN-XiaoxiaoNeural": tr("voice.female_gentle"),
|
|
"zh-CN-XiaoyiNeural": tr("voice.female_energetic"),
|
|
}[x],
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# Voice preview button
|
|
if st.button(tr("voice.preview"), key="preview_voice", use_container_width=True):
|
|
with st.spinner(tr("voice.previewing")):
|
|
try:
|
|
# Generate preview audio
|
|
preview_text = "大家好,这是一段测试语音。"
|
|
|
|
# Use TTS service to generate audio (auto temp path)
|
|
audio_path = run_async(reelforge.tts(
|
|
text=preview_text,
|
|
voice=voice_id
|
|
))
|
|
|
|
# Play the audio
|
|
if os.path.exists(audio_path):
|
|
st.audio(audio_path, format="audio/mp3")
|
|
else:
|
|
st.error("Failed to generate preview audio")
|
|
except Exception as e:
|
|
st.error(tr("voice.preview_failed", error=str(e)))
|
|
logger.exception(e)
|
|
|
|
st.markdown("---")
|
|
|
|
# Visual style (Illustration Style)
|
|
st.markdown(f"**{tr('style.title')}**")
|
|
|
|
# Get available presets dynamically from StylePreset enum
|
|
from reelforge.services.final_image_prompt import StylePreset
|
|
|
|
# Build preset options (enum values + custom)
|
|
preset_options = [preset.name.lower() for preset in StylePreset]
|
|
preset_options.append("custom")
|
|
|
|
# Dynamic display name function
|
|
def get_preset_display(preset_name: str) -> str:
|
|
if preset_name == "custom":
|
|
return tr("style.custom")
|
|
# Find the enum by name and get its display_name
|
|
for preset in StylePreset:
|
|
if preset.name.lower() == preset_name:
|
|
return preset.value.display_name
|
|
return preset_name.replace("_", " ").title()
|
|
|
|
# Change from radio to selectbox (dropdown)
|
|
style_preset = st.selectbox(
|
|
"Style Preset",
|
|
preset_options,
|
|
format_func=get_preset_display,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# Custom style description input (only show for custom mode)
|
|
custom_style_description = ""
|
|
if style_preset == "custom":
|
|
custom_style_description = st.text_area(
|
|
tr("style.description"),
|
|
placeholder=tr("style.description_placeholder"),
|
|
height=80,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# Visual style preview button
|
|
if st.button(tr("style.preview"), key="preview_style", use_container_width=True):
|
|
with st.spinner(tr("style.previewing")):
|
|
try:
|
|
# Generate preview using cached function
|
|
preset_name = style_preset if style_preset != "custom" else ""
|
|
custom_desc = custom_style_description if style_preset == "custom" else ""
|
|
|
|
preview_image_path, generated_prompt = generate_style_preview_cached(
|
|
style_preset_name=preset_name,
|
|
custom_style_description=custom_desc
|
|
)
|
|
|
|
# Show generated prompt if custom mode
|
|
if generated_prompt:
|
|
st.info(tr("style.generated_prompt", prompt=generated_prompt))
|
|
|
|
# Display preview (support both URL and local path)
|
|
if preview_image_path:
|
|
# Read and encode image
|
|
if preview_image_path.startswith('http'):
|
|
# URL - use directly
|
|
img_html = f'<div class="preview-image"><img src="{preview_image_path}" alt="Style Preview"/></div>'
|
|
else:
|
|
# Local file - encode as base64
|
|
with open(preview_image_path, 'rb') as f:
|
|
img_data = base64.b64encode(f.read()).decode()
|
|
img_html = f'<div class="preview-image"><img src="data:image/png;base64,{img_data}" alt="Style Preview"/></div>'
|
|
|
|
st.markdown(img_html, unsafe_allow_html=True)
|
|
st.caption(tr("style.preview_caption"))
|
|
else:
|
|
st.error("Failed to generate preview image")
|
|
except Exception as e:
|
|
st.error(tr("style.preview_failed", error=str(e)))
|
|
logger.exception(e)
|
|
|
|
st.markdown("---")
|
|
|
|
# Frame template (moved from right column)
|
|
st.markdown(f"**{tr('template.title')}**")
|
|
frame_template = st.selectbox(
|
|
"Template",
|
|
["classic", "modern", "neon"],
|
|
format_func=lambda x: tr(f"template.{x}"),
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# ========================================================================
|
|
# Right Column: Generate Button + Progress + Video Preview
|
|
# ========================================================================
|
|
with right_col:
|
|
with st.container(border=True):
|
|
st.markdown(f"**{tr('section.video_generation')}**")
|
|
|
|
# Check if system is configured
|
|
if not config_manager.validate():
|
|
st.warning(tr("settings.not_configured"))
|
|
|
|
# Generate Button
|
|
if st.button(tr("btn.generate"), type="primary", use_container_width=True):
|
|
# Validate system configuration
|
|
if not config_manager.validate():
|
|
st.error(tr("settings.not_configured"))
|
|
st.stop()
|
|
|
|
# Validate input
|
|
if not book_name and not topic and not content:
|
|
st.error(tr("error.input_required"))
|
|
st.stop()
|
|
|
|
# Show progress
|
|
progress_bar = st.progress(0)
|
|
status_text = st.empty()
|
|
|
|
try:
|
|
# Progress callback to update UI
|
|
def update_progress(event: ProgressEvent):
|
|
"""Update progress bar and status text from ProgressEvent"""
|
|
# Translate event to user-facing message
|
|
if event.event_type == "frame_step":
|
|
# Frame step: "分镜 3/5 - 步骤 2/4: 生成插图"
|
|
action_key = f"progress.step_{event.action}"
|
|
action_text = tr(action_key)
|
|
message = tr(
|
|
"progress.frame_step",
|
|
current=event.frame_current,
|
|
total=event.frame_total,
|
|
step=event.step,
|
|
action=action_text
|
|
)
|
|
elif event.event_type == "processing_frame":
|
|
# Processing frame: "分镜 3/5"
|
|
message = tr(
|
|
"progress.frame",
|
|
current=event.frame_current,
|
|
total=event.frame_total
|
|
)
|
|
else:
|
|
# Simple events: use i18n key directly
|
|
message = tr(f"progress.{event.event_type}")
|
|
|
|
status_text.text(message)
|
|
progress_bar.progress(min(int(event.progress * 100), 99)) # Cap at 99% until complete
|
|
|
|
# Prepare image style parameters
|
|
style_preset_param = None
|
|
style_description_param = None
|
|
|
|
if style_preset == "custom":
|
|
# Custom mode: pass user description as-is
|
|
style_description_param = custom_style_description
|
|
else:
|
|
# Preset mode: pass preset name
|
|
style_preset_param = style_preset
|
|
|
|
result = run_async(reelforge.generate_book_video(
|
|
book_name=book_name if book_name else None,
|
|
topic=topic if topic else None,
|
|
content=content if content else None,
|
|
title=title if title else None,
|
|
n_frames=n_frames,
|
|
voice_id=voice_id,
|
|
image_style_preset=style_preset_param,
|
|
image_style_description=style_description_param,
|
|
frame_template=frame_template,
|
|
bgm_path=bgm_path,
|
|
progress_callback=update_progress,
|
|
))
|
|
|
|
progress_bar.progress(100)
|
|
status_text.text(tr("status.success"))
|
|
|
|
# Display success message
|
|
st.success(tr("status.video_generated", path=result.video_path))
|
|
|
|
st.markdown("---")
|
|
|
|
# Video information (compact display)
|
|
file_size_mb = result.file_size / (1024 * 1024)
|
|
info_text = (
|
|
f"⏱️ {result.duration:.1f}s "
|
|
f"📦 {file_size_mb:.2f}MB "
|
|
f"🎬 {len(result.storyboard.frames)}{tr('info.scenes_unit')} "
|
|
f"📐 {result.storyboard.config.video_width}x{result.storyboard.config.video_height}"
|
|
)
|
|
st.caption(info_text)
|
|
|
|
st.markdown("---")
|
|
|
|
# Video preview
|
|
if os.path.exists(result.video_path):
|
|
st.video(result.video_path)
|
|
else:
|
|
st.error(tr("status.video_not_found", path=result.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()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|