mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-06-16 20:32:06 +00:00
- 新增竖屏/横屏字幕安全区预览背景图,支持切换预览比例 - 将项目版本从0.8.1升级至0.8.2 - 扩展字体搜索候选列表,新增SourceHanSerifSC-SemiBold.otf和LXGWWenKaiScreen.ttf两款字体 - 修改默认字幕字体为SourceHanSansCN-Regular.otf,替换原Microsoft YaHei默认值 - 新增内置字体检测逻辑,检测到resource/fonts目录有有效字体时跳过下载 - 更新中英文多语言文案,优化字幕位置提示文本 - 重构字幕设置面板,合并位置控制到预览区域并精简标签页 - 调整字体大小滑块范围从20-100扩展至20-160,新增数值边界校验
721 lines
28 KiB
Python
721 lines
28 KiB
Python
import streamlit as st
|
||
import os
|
||
import sys
|
||
import time
|
||
from html import escape
|
||
from loguru import logger
|
||
from app.config import config
|
||
from webui.components import basic_settings, video_settings, audio_settings, subtitle_settings, script_settings, \
|
||
system_settings
|
||
# from webui.utils import cache, file_utils
|
||
from app.utils import utils
|
||
from app.utils import ffmpeg_utils
|
||
from app.models import const
|
||
from app.models.schema import VideoClipParams, VideoAspect
|
||
|
||
|
||
# 初始化配置 - 必须是第一个 Streamlit 命令
|
||
st.set_page_config(
|
||
page_title="NarratoAI",
|
||
page_icon="📽️",
|
||
layout="wide",
|
||
initial_sidebar_state="auto",
|
||
menu_items={
|
||
"Report a bug": "https://github.com/linyqh/NarratoAI/issues",
|
||
'About': f"# Narrato:blue[AI] :sunglasses: 📽️ \n #### Version: v{config.project_version} \n "
|
||
f"自动化影视解说视频详情请移步:https://github.com/linyqh/NarratoAI"
|
||
},
|
||
)
|
||
|
||
# 设置页面样式
|
||
hide_streamlit_style = """
|
||
<style>#root > div:nth-child(1) > div > div > div > div > section > div {padding-top: 2rem; padding-bottom: 10px; padding-left: 20px; padding-right: 20px;}</style>
|
||
"""
|
||
st.markdown(hide_streamlit_style, unsafe_allow_html=True)
|
||
|
||
|
||
def init_log():
|
||
"""初始化日志配置"""
|
||
from loguru import logger
|
||
logger.remove()
|
||
_lvl = "INFO" # 改为 INFO 级别,过滤掉 DEBUG 日志
|
||
|
||
def format_record(record):
|
||
# 简化日志格式化处理,不尝试按特定字符串过滤torch相关内容
|
||
file_path = record["file"].path
|
||
relative_path = os.path.relpath(file_path, config.root_dir)
|
||
record["file"].path = f"./{relative_path}"
|
||
record['message'] = record['message'].replace(config.root_dir, ".")
|
||
|
||
_format = '<green>{time:%Y-%m-%d %H:%M:%S}</> | ' + \
|
||
'<level>{level}</> | ' + \
|
||
'"{file.path}:{line}":<blue> {function}</> ' + \
|
||
'- <level>{message}</>' + "\n"
|
||
return _format
|
||
|
||
# 添加日志过滤器
|
||
def log_filter(record):
|
||
"""过滤不必要的日志消息"""
|
||
# 过滤掉启动时的噪音日志(即使在 DEBUG 模式下也可以选择过滤)
|
||
ignore_patterns = [
|
||
"Examining the path of torch.classes raised",
|
||
"torch.cuda.is_available()",
|
||
"CUDA initialization"
|
||
]
|
||
return not any(pattern in record["message"] for pattern in ignore_patterns)
|
||
|
||
logger.add(
|
||
sys.stdout,
|
||
level=_lvl,
|
||
format=format_record,
|
||
colorize=True,
|
||
filter=log_filter
|
||
)
|
||
|
||
# 应用启动后,可以再添加更复杂的过滤器
|
||
def setup_advanced_filters():
|
||
"""在应用完全启动后设置高级过滤器"""
|
||
try:
|
||
for handler_id in logger._core.handlers:
|
||
logger.remove(handler_id)
|
||
|
||
# 重新添加带有高级过滤的处理器
|
||
def advanced_filter(record):
|
||
"""更复杂的过滤器,在应用启动后安全使用"""
|
||
ignore_messages = [
|
||
"Examining the path of torch.classes raised",
|
||
"torch.cuda.is_available()",
|
||
"CUDA initialization"
|
||
]
|
||
return not any(msg in record["message"] for msg in ignore_messages)
|
||
|
||
logger.add(
|
||
sys.stdout,
|
||
level=_lvl,
|
||
format=format_record,
|
||
colorize=True,
|
||
filter=advanced_filter
|
||
)
|
||
except Exception as e:
|
||
# 如果过滤器设置失败,确保日志仍然可用
|
||
logger.add(
|
||
sys.stdout,
|
||
level=_lvl,
|
||
format=format_record,
|
||
colorize=True
|
||
)
|
||
logger.error(f"设置高级日志过滤器失败: {e}")
|
||
|
||
# 将高级过滤器设置放到启动主逻辑后
|
||
import threading
|
||
threading.Timer(5.0, setup_advanced_filters).start()
|
||
|
||
|
||
def init_global_state():
|
||
"""初始化全局状态"""
|
||
if 'video_clip_json' not in st.session_state:
|
||
st.session_state['video_clip_json'] = []
|
||
if 'video_plot' not in st.session_state:
|
||
st.session_state['video_plot'] = ''
|
||
if 'ui_language' not in st.session_state:
|
||
st.session_state['ui_language'] = config.ui.get("language", utils.get_system_locale())
|
||
# 移除subclip_videos初始化 - 现在使用统一裁剪策略
|
||
|
||
|
||
def tr(key):
|
||
"""翻译函数"""
|
||
i18n_dir = os.path.join(os.path.dirname(__file__), "webui", "i18n")
|
||
locales = utils.load_locales(i18n_dir)
|
||
loc = locales.get(st.session_state['ui_language'], {})
|
||
return loc.get("Translation", {}).get(key, key)
|
||
|
||
|
||
VIDEO_GENERATION_STEP_LABELS = [
|
||
"正在加载剪辑脚本",
|
||
"正在生成 TTS 配音",
|
||
"正在按脚本裁剪视频片段",
|
||
"正在合并配音和字幕",
|
||
"正在合并视频片段",
|
||
"正在合成最终视频",
|
||
]
|
||
|
||
|
||
def _safe_int(value, default=0):
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _format_optional_percent(value):
|
||
try:
|
||
percent = max(0.0, min(100.0, float(value)))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if percent.is_integer():
|
||
return str(int(percent))
|
||
return f"{percent:.1f}"
|
||
|
||
|
||
def _render_generation_status(task: dict | None) -> str:
|
||
task = task or {}
|
||
state = task.get("state")
|
||
current_step = _safe_int(task.get("step_current"), 0)
|
||
step_total = _safe_int(task.get("step_total"), len(VIDEO_GENERATION_STEP_LABELS))
|
||
message = str(task.get("message") or "")
|
||
ffmpeg_percent = _format_optional_percent(task.get("ffmpeg_progress"))
|
||
|
||
if current_step <= 0:
|
||
return f"<div style='font-weight:650;color:#262730;'>{escape(message or '正在生成视频,请稍候...')}</div>"
|
||
|
||
lines = []
|
||
for index, default_label in enumerate(VIDEO_GENERATION_STEP_LABELS, start=1):
|
||
is_current = index == current_step
|
||
is_complete = state == const.TASK_STATE_COMPLETE
|
||
is_done = is_complete or index < current_step
|
||
label = message if is_current and message else default_label
|
||
|
||
suffix = f"{index}/{step_total}"
|
||
if (
|
||
is_current
|
||
and index == step_total
|
||
and ffmpeg_percent is not None
|
||
and not is_complete
|
||
):
|
||
suffix = f"{suffix},ffmpeg {ffmpeg_percent}%"
|
||
|
||
color = "#262730" if is_current else "#8b9099" if is_done else "#b9bec7"
|
||
weight = "650" if is_current else "500"
|
||
lines.append(
|
||
"<div style='"
|
||
"font-size:1.02rem;"
|
||
"line-height:1.85;"
|
||
"margin:0.28rem 0;"
|
||
f"color:{color};"
|
||
f"font-weight:{weight};"
|
||
"'>"
|
||
f"{escape(label)} <span style='white-space:nowrap;'>({escape(suffix)})</span>"
|
||
"</div>"
|
||
)
|
||
|
||
return "".join(lines)
|
||
|
||
|
||
def get_help_text():
|
||
"""返回带当前项目版本号的帮助文案"""
|
||
return tr("Get Help").replace("🎉🎉🎉", f" v{config.project_version}")
|
||
|
||
|
||
def render_generate_button():
|
||
"""渲染生成按钮和处理逻辑"""
|
||
if st.button(tr("Generate Video"), use_container_width=True, type="primary"):
|
||
from app.services import task as tm
|
||
from app.services import state as sm
|
||
from app.models import const
|
||
import threading
|
||
import time
|
||
import uuid
|
||
|
||
config.save_config()
|
||
|
||
# 移除task_id检查 - 现在使用统一裁剪策略,不再需要预裁剪
|
||
# 直接检查必要的文件是否存在
|
||
if not st.session_state.get('video_clip_json_path'):
|
||
st.error(tr("Script file cannot be empty"))
|
||
return
|
||
if not st.session_state.get('video_origin_path'):
|
||
st.error(tr("Video file cannot be empty"))
|
||
return
|
||
|
||
# 获取所有参数
|
||
script_params = script_settings.get_script_params()
|
||
video_params = video_settings.get_video_params()
|
||
audio_params = audio_settings.get_audio_params()
|
||
subtitle_params = subtitle_settings.get_subtitle_params()
|
||
|
||
# 合并所有参数
|
||
all_params = {
|
||
**script_params,
|
||
**video_params,
|
||
**audio_params,
|
||
**subtitle_params
|
||
}
|
||
|
||
# 创建参数对象
|
||
params = VideoClipParams(**all_params)
|
||
|
||
# 生成一个新的task_id用于本次处理
|
||
task_id = str(uuid.uuid4())
|
||
|
||
@st.dialog(tr("Generating Video"), width="large")
|
||
def generate_video_dialog():
|
||
st.markdown(
|
||
"""
|
||
<style>
|
||
div[data-testid="stDialog"] div[data-testid="stStatusWidget"] {
|
||
margin-top: 0.25rem;
|
||
}
|
||
div[data-testid="stDialog"] div[data-testid="stProgress"] {
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
div[data-testid="stDialog"] video {
|
||
max-height: 62vh;
|
||
object-fit: contain;
|
||
background: #000;
|
||
}
|
||
</style>
|
||
""",
|
||
unsafe_allow_html=True,
|
||
)
|
||
|
||
progress_bar = st.progress(0)
|
||
status_panel = st.status(tr("Generating Video"), expanded=True)
|
||
with status_panel:
|
||
status_placeholder = st.empty()
|
||
status_placeholder.markdown(
|
||
_render_generation_status(None),
|
||
unsafe_allow_html=True,
|
||
)
|
||
|
||
def run_task():
|
||
try:
|
||
tm.start_subclip_unified(
|
||
task_id=task_id,
|
||
params=params
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"任务执行失败: {e}")
|
||
current_task = sm.state.get_task(task_id) or {}
|
||
sm.state.update_task(
|
||
task_id,
|
||
state=const.TASK_STATE_FAILED,
|
||
progress=current_task.get("progress", 0),
|
||
message=str(e),
|
||
)
|
||
|
||
# 在新线程中启动任务
|
||
thread = threading.Thread(target=run_task)
|
||
thread.start()
|
||
|
||
last_status_key = None
|
||
|
||
# 轮询任务状态
|
||
while True:
|
||
task = sm.state.get_task(task_id)
|
||
if task:
|
||
progress = task.get("progress", 0)
|
||
state = task.get("state")
|
||
|
||
try:
|
||
progress = int(progress)
|
||
except (TypeError, ValueError):
|
||
progress = 0
|
||
progress = max(0, min(progress, 100))
|
||
|
||
# 更新进度条和阶段状态
|
||
progress_bar.progress(progress / 100)
|
||
current_message = task.get("message") or f"Processing... {progress}%"
|
||
status_key = (
|
||
state,
|
||
progress,
|
||
current_message,
|
||
task.get("step_current"),
|
||
task.get("step_total"),
|
||
task.get("ffmpeg_progress"),
|
||
)
|
||
if status_key != last_status_key:
|
||
status_placeholder.markdown(
|
||
_render_generation_status(task),
|
||
unsafe_allow_html=True,
|
||
)
|
||
last_status_key = status_key
|
||
|
||
if state == const.TASK_STATE_COMPLETE:
|
||
status_panel.update(
|
||
label=tr("Video Generation Completed"),
|
||
state="complete",
|
||
expanded=False,
|
||
)
|
||
progress_bar.progress(1.0)
|
||
|
||
# 显示结果
|
||
video_files = task.get("videos", [])
|
||
try:
|
||
if video_files:
|
||
aspect = getattr(params, "video_aspect", "")
|
||
aspect = getattr(aspect, "value", aspect)
|
||
preview_width = 320 if aspect in {
|
||
VideoAspect.portrait.value,
|
||
VideoAspect.portrait_2.value,
|
||
} else 600
|
||
for url in video_files:
|
||
_, preview_col, _ = st.columns([1, 2, 1])
|
||
with preview_col:
|
||
st.video(url, width=preview_width)
|
||
except Exception as e:
|
||
logger.error(f"播放视频失败: {e}")
|
||
|
||
st.success(tr("Video Generation Completed"))
|
||
break
|
||
|
||
if state == const.TASK_STATE_FAILED:
|
||
status_panel.update(
|
||
label=f"{tr('Task failed')}: {task.get('message', 'Unknown error')}",
|
||
state="error",
|
||
expanded=True,
|
||
)
|
||
st.error(f"{tr('Task failed')}: {task.get('message', 'Unknown error')}")
|
||
break
|
||
|
||
time.sleep(0.5)
|
||
|
||
generate_video_dialog()
|
||
|
||
|
||
def get_voice_name_for_tts_engine(tts_engine: str) -> str:
|
||
"""根据TTS引擎获取用户选择的音色"""
|
||
if tts_engine == 'edge_tts':
|
||
return config.ui.get('edge_voice_name', 'zh-CN-XiaoxiaoNeural-Female')
|
||
if tts_engine == 'azure_speech':
|
||
return config.ui.get('azure_voice_name', 'zh-CN-XiaoxiaoMultilingualNeural')
|
||
if tts_engine == 'tencent_tts':
|
||
return f"tencent:{config.ui.get('tencent_voice_type', '101001')}"
|
||
if tts_engine == 'qwen3_tts':
|
||
return f"qwen3:{config.ui.get('qwen_voice_type', 'Cherry')}"
|
||
if tts_engine == config.INDEXTTS2_ENGINE:
|
||
reference_audio = config.indextts2.get('reference_audio', '')
|
||
if reference_audio:
|
||
return f"{config.INDEXTTS2_VOICE_PREFIX}{reference_audio}"
|
||
return config.ui.get('voice_name', '')
|
||
if config.normalize_tts_engine_name(tts_engine) == config.INDEXTTS_ENGINE:
|
||
reference_audio = config.indextts.get('reference_audio', '')
|
||
if reference_audio:
|
||
return f"{config.INDEXTTS_VOICE_PREFIX}{reference_audio}"
|
||
return config.ui.get('voice_name', '')
|
||
if tts_engine == config.OMNIVOICE_ENGINE:
|
||
mode = config.omnivoice.get('mode', 'auto')
|
||
reference_audio = config.omnivoice.get('reference_audio', '')
|
||
if mode == 'voice_clone' and reference_audio:
|
||
return f"{config.OMNIVOICE_VOICE_PREFIX}{reference_audio}"
|
||
return f"{config.OMNIVOICE_VOICE_PREFIX}{mode}"
|
||
if tts_engine == 'doubaotts':
|
||
return config.ui.get('doubaotts_voice_type', 'BV700_streaming')
|
||
if tts_engine == 'soulvoice':
|
||
voice_uri = config.soulvoice.get('voice_uri', '')
|
||
if voice_uri and not voice_uri.startswith(('soulvoice:', 'speech:')):
|
||
return f"soulvoice:{voice_uri}"
|
||
return voice_uri
|
||
return config.ui.get('voice_name', config.ui.get('edge_voice_name', 'zh-CN-XiaoxiaoNeural-Female'))
|
||
|
||
|
||
def get_jianying_export_params(draft_name=None) -> VideoClipParams:
|
||
"""获取导出到剪映草稿的参数"""
|
||
tts_engine = st.session_state.get('tts_engine', config.ui.get('tts_engine', 'edge_tts'))
|
||
voice_name = get_voice_name_for_tts_engine(tts_engine)
|
||
voice_rate = st.session_state.get('voice_rate', 1.0)
|
||
voice_pitch = st.session_state.get('voice_pitch', 1.0)
|
||
subtitle_paths = st.session_state.get('subtitle_paths', [])
|
||
if isinstance(subtitle_paths, str):
|
||
subtitle_paths = [subtitle_paths]
|
||
subtitle_paths = [
|
||
path for path in subtitle_paths
|
||
if isinstance(path, str) and path.strip()
|
||
]
|
||
if not subtitle_paths and st.session_state.get('subtitle_path'):
|
||
subtitle_paths = [st.session_state.get('subtitle_path')]
|
||
|
||
return VideoClipParams(
|
||
video_clip_json_path=st.session_state['video_clip_json_path'],
|
||
video_origin_path=st.session_state['video_origin_path'],
|
||
video_origin_paths=st.session_state.get('video_origin_paths', []),
|
||
original_subtitle_path=subtitle_paths[0] if subtitle_paths else "",
|
||
original_subtitle_paths=subtitle_paths,
|
||
tts_engine=tts_engine,
|
||
voice_name=voice_name,
|
||
voice_rate=voice_rate,
|
||
voice_pitch=voice_pitch,
|
||
n_threads=config.app.get('n_threads', 4),
|
||
video_aspect=VideoAspect.landscape,
|
||
subtitle_enabled=st.session_state.get('subtitle_enabled', False),
|
||
font_name=st.session_state.get('font_name', 'SourceHanSansCN-Regular.otf'),
|
||
font_size=st.session_state.get('font_size', 24),
|
||
text_fore_color=st.session_state.get('text_fore_color', '#FFFFFF'),
|
||
subtitle_position=st.session_state.get('subtitle_position', 'bottom'),
|
||
custom_position=st.session_state.get('custom_position', 70.0),
|
||
tts_volume=st.session_state.get('tts_volume', 1.0),
|
||
original_volume=st.session_state.get('original_volume', 0.7),
|
||
bgm_volume=st.session_state.get('bgm_volume', 0.3),
|
||
draft_name=(
|
||
draft_name
|
||
if draft_name is not None
|
||
else st.session_state.get('draft_name_input', f"NarratoAI_{int(time.time())}")
|
||
)
|
||
)
|
||
|
||
|
||
def _render_jianying_export_status():
|
||
"""渲染剪映导出的结果提示。"""
|
||
result = st.session_state.get('jianying_export_result')
|
||
error = st.session_state.get('jianying_export_error')
|
||
|
||
if result:
|
||
st.success(tr("Jianying draft exported successfully").format(name=result['draft_name']))
|
||
st.info(tr("Draft saved to").format(path=result['draft_path']))
|
||
elif error:
|
||
st.error(f"{tr('Failed to export Jianying draft')}: {error}")
|
||
|
||
|
||
def _render_jianying_export_dialog():
|
||
"""使用弹窗确认剪映草稿名称。"""
|
||
import uuid
|
||
from loguru import logger
|
||
|
||
@st.dialog(tr("Export to Jianying Draft"), width="small")
|
||
def jianying_export_dialog():
|
||
jianying_draft_path = config.ui.get("jianying_draft_path", "")
|
||
dialog_title = escape(tr("Jianying export dialog title"))
|
||
dialog_description = escape(tr("Jianying export dialog description"))
|
||
destination_label = escape(tr("Jianying export destination"))
|
||
destination_path = escape(jianying_draft_path or "-")
|
||
|
||
st.markdown(
|
||
f"""
|
||
<style>
|
||
.jianying-export-panel {{
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
padding: 14px;
|
||
margin: 2px 0 18px;
|
||
border: 1px solid rgba(255, 75, 75, 0.24);
|
||
border-radius: 8px;
|
||
background: linear-gradient(135deg, rgba(255, 75, 75, 0.10), rgba(255, 255, 255, 0.96));
|
||
}}
|
||
.jianying-export-icon {{
|
||
width: 38px;
|
||
height: 38px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
border-radius: 8px;
|
||
color: #ffffff;
|
||
background: #ff4b4b;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
}}
|
||
.jianying-export-title {{
|
||
color: #202534;
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
line-height: 1.35;
|
||
margin-bottom: 4px;
|
||
}}
|
||
.jianying-export-description {{
|
||
color: #5f6575;
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}}
|
||
.jianying-export-path {{
|
||
padding: 10px 12px;
|
||
margin: 2px 0 16px;
|
||
border: 1px solid #e4e7ef;
|
||
border-radius: 8px;
|
||
background: #f8f9fc;
|
||
color: #323846;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
word-break: break-all;
|
||
}}
|
||
.jianying-export-path-label {{
|
||
display: block;
|
||
color: #7a8192;
|
||
font-size: 12px;
|
||
margin-bottom: 4px;
|
||
}}
|
||
</style>
|
||
<div class="jianying-export-panel">
|
||
<div class="jianying-export-icon">📤</div>
|
||
<div>
|
||
<div class="jianying-export-title">{dialog_title}</div>
|
||
<div class="jianying-export-description">{dialog_description}</div>
|
||
</div>
|
||
</div>
|
||
<div class="jianying-export-path">
|
||
<span class="jianying-export-path-label">{destination_label}</span>
|
||
{destination_path}
|
||
</div>
|
||
""",
|
||
unsafe_allow_html=True,
|
||
)
|
||
|
||
draft_name = st.text_input(
|
||
tr("Jianying draft name"),
|
||
key="draft_name_input",
|
||
placeholder="NarratoAI_",
|
||
)
|
||
|
||
error = st.session_state.get('jianying_export_error')
|
||
if error:
|
||
st.error(f"{tr('Failed to export Jianying draft')}: {error}")
|
||
|
||
cancel_col, confirm_col = st.columns(2)
|
||
with cancel_col:
|
||
if st.button(tr("Cancel"), key="cancel_export", use_container_width=True):
|
||
st.session_state['jianying_export_error'] = None
|
||
st.rerun()
|
||
|
||
with confirm_col:
|
||
if st.button(tr("Confirm Export"), key="confirm_export", type="primary", use_container_width=True):
|
||
draft_name = (draft_name or "").strip()
|
||
if not draft_name:
|
||
st.error(tr("Please enter draft name"))
|
||
return
|
||
|
||
# 创建任务ID
|
||
task_id = str(uuid.uuid4())
|
||
st.session_state['task_id'] = task_id
|
||
|
||
# 构建参数
|
||
try:
|
||
params = get_jianying_export_params(draft_name)
|
||
except Exception as e:
|
||
logger.error(f"构建参数失败: {e}")
|
||
st.session_state['jianying_export_error'] = f"{tr('Failed to build parameters')}: {e}"
|
||
st.error(st.session_state['jianying_export_error'])
|
||
return
|
||
|
||
with st.spinner(tr("Exporting to Jianying draft...")):
|
||
try:
|
||
from app.services import jianying_task
|
||
|
||
# 调用导出到剪映草稿的任务
|
||
result = jianying_task.start_export_jianying_draft(task_id, params)
|
||
|
||
# 记录日志
|
||
logger.info(f"成功导出到剪映草稿: {result['draft_name']}")
|
||
logger.info(f"草稿已保存到: {result['draft_path']}")
|
||
|
||
# 保存结果到session state
|
||
st.session_state['jianying_export_result'] = result
|
||
st.session_state['jianying_export_error'] = None
|
||
st.rerun()
|
||
except Exception as e:
|
||
logger.error(f"导出到剪映草稿失败: {e}")
|
||
import traceback
|
||
logger.error(f"错误详情: {traceback.format_exc()}")
|
||
st.session_state['jianying_export_error'] = str(e)
|
||
st.session_state['jianying_export_result'] = None
|
||
st.error(f"{tr('Failed to export Jianying draft')}: {e}")
|
||
|
||
jianying_export_dialog()
|
||
|
||
|
||
def render_export_jianying_button():
|
||
"""渲染导出到剪映草稿按钮和处理逻辑"""
|
||
import os
|
||
import time
|
||
|
||
# 初始化session state
|
||
if 'jianying_export_result' not in st.session_state:
|
||
st.session_state['jianying_export_result'] = None
|
||
if 'jianying_export_error' not in st.session_state:
|
||
st.session_state['jianying_export_error'] = None
|
||
|
||
if st.button(tr("Export to Jianying Draft"), use_container_width=True, type="secondary"):
|
||
config.save_config()
|
||
|
||
if not st.session_state.get('video_clip_json_path'):
|
||
st.error(tr("Script file cannot be empty"))
|
||
return
|
||
if not st.session_state.get('video_origin_path'):
|
||
st.error(tr("Video file cannot be empty"))
|
||
return
|
||
|
||
jianying_draft_path = config.ui.get("jianying_draft_path", "")
|
||
if not jianying_draft_path:
|
||
st.error(tr("Please configure Jianying draft folder in basic settings"))
|
||
return
|
||
|
||
if not os.path.exists(jianying_draft_path):
|
||
st.error(tr("Jianying draft folder does not exist").format(path=jianying_draft_path))
|
||
return
|
||
|
||
st.session_state['jianying_export_result'] = None
|
||
st.session_state['jianying_export_error'] = None
|
||
st.session_state['draft_name_input'] = f"NarratoAI_{int(time.time())}"
|
||
_render_jianying_export_dialog()
|
||
|
||
_render_jianying_export_status()
|
||
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
init_log()
|
||
init_global_state()
|
||
|
||
# ===== 显式注册 LLM 提供商(最佳实践)=====
|
||
# 在应用启动时立即注册,确保所有 LLM 功能可用
|
||
if 'llm_providers_registered' not in st.session_state:
|
||
try:
|
||
from app.services.llm.providers import register_all_providers
|
||
register_all_providers()
|
||
st.session_state['llm_providers_registered'] = True
|
||
logger.info("✅ LLM 提供商注册成功")
|
||
except Exception as e:
|
||
logger.error(f"❌ LLM 提供商注册失败: {str(e)}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
st.error(tr("LLM initialization failed").format(error=str(e)))
|
||
# 不抛出异常,允许应用继续运行(但 LLM 功能不可用)
|
||
|
||
# 检测FFmpeg硬件加速,但只打印一次日志(使用 session_state 持久化)
|
||
if 'hwaccel_logged' not in st.session_state:
|
||
st.session_state['hwaccel_logged'] = False
|
||
|
||
hwaccel_info = ffmpeg_utils.detect_hardware_acceleration()
|
||
if not st.session_state['hwaccel_logged']:
|
||
if hwaccel_info["available"]:
|
||
logger.info(f"FFmpeg硬件加速检测结果: 可用 | 类型: {hwaccel_info['type']} | 编码器: {hwaccel_info['encoder']} | 独立显卡: {hwaccel_info['is_dedicated_gpu']}")
|
||
else:
|
||
logger.warning(f"FFmpeg硬件加速不可用: {hwaccel_info['message']}, 将使用CPU软件编码")
|
||
st.session_state['hwaccel_logged'] = True
|
||
|
||
# 仅初始化基本资源,避免过早地加载依赖PyTorch的资源
|
||
# 检查是否能分解utils.init_resources()为基本资源和高级资源(如依赖PyTorch的资源)
|
||
try:
|
||
utils.init_resources()
|
||
except Exception as e:
|
||
logger.warning(f"资源初始化时出现警告: {e}")
|
||
|
||
st.title(f"Narrato:blue[AI]:sunglasses: 📽️")
|
||
st.write(get_help_text())
|
||
|
||
# 首先渲染不依赖PyTorch的UI部分
|
||
# 渲染基础设置面板
|
||
basic_settings.render_basic_settings(tr)
|
||
|
||
# 渲染主面板
|
||
panel = st.columns(3)
|
||
with panel[0]:
|
||
script_settings.render_script_panel(tr)
|
||
with panel[1]:
|
||
audio_settings.render_audio_panel(tr)
|
||
with panel[2]:
|
||
video_settings.render_video_panel(tr)
|
||
subtitle_settings.render_subtitle_panel(tr)
|
||
|
||
# 放到最后渲染可能使用PyTorch的部分
|
||
# 渲染系统设置面板
|
||
with panel[2]:
|
||
system_settings.render_system_panel(tr)
|
||
|
||
# 放到最后渲染生成按钮和处理逻辑
|
||
render_generate_button()
|
||
render_export_jianying_button()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|