feat(webui): 新增短剧剧情分析、可视化脚本编辑器与通用生成参数设置

- 抽离通用生成参数设置组件,统一管理temperature等LLM生成参数
- 新增短剧字幕剧情分析功能,支持一键分析与手动编辑分析结果
- 重构短剧脚本生成逻辑,支持传入预先生成的剧情分析内容
- 新增可视化视频脚本表格编辑器,支持增删编辑行与原始JSON预览
- 优化多语言翻译、UI交互细节与字幕相关提示文案
This commit is contained in:
viccy 2026-06-05 19:31:35 +08:00
parent e744960ac1
commit e6d15fe246
5 changed files with 361 additions and 48 deletions

View File

@ -167,6 +167,17 @@ def render_basic_settings(tr):
with right_config_panel:
render_text_llm_settings(tr) # 文案生成模型设置
render_generation_settings(tr)
def render_generation_settings(tr):
"""渲染通用生成参数。"""
st.divider()
st.subheader(tr("Generation Settings"))
if 'temperature' not in st.session_state:
st.session_state['temperature'] = 0.7
st.slider("temperature", 0.0, 2.0, key="temperature")
def render_language_settings(tr):
st.subheader(tr("Proxy Settings"))

View File

@ -1,18 +1,23 @@
import os
import glob
import json
import math
import time
import traceback
import pandas as pd
import streamlit as st
from loguru import logger
from app.config import config
from app.models.schema import VideoClipParams
from app.services.subtitle_text import decode_subtitle_bytes
from app.services.subtitle_text import decode_subtitle_bytes, read_subtitle_text
from app.utils import utils, check_script
from webui.tools.generate_script_docu import generate_script_docu
from webui.tools.generate_script_short import generate_script_short
from webui.tools.generate_short_summary import generate_script_short_sunmmary
from webui.tools.generate_short_summary import analyze_short_drama_plot, generate_script_short_sunmmary
SCRIPT_TABLE_BASE_COLUMNS = ["_id", "timestamp", "picture", "narration", "OST"]
def render_script_panel(tr):
@ -77,8 +82,10 @@ def render_script_file(tr, params):
default_index = mode_keys.index(tr("Short Generate"))
elif current_path == "summary":
default_index = mode_keys.index(tr("Short Drama Summary"))
else:
elif current_path:
default_index = mode_keys.index(tr("Select/Upload Script"))
else:
default_index = 0
# 1. 渲染功能选择组件
default_mode_label = mode_keys[default_index]
@ -230,16 +237,21 @@ def render_script_file(tr, params):
def render_video_file(tr, params):
"""渲染视频文件选择"""
source_options = {
tr("Select from resource directory"): "resource",
tr("Upload Local Files"): "upload",
tr("Select from resource directory"): "resource",
}
source_labels = list(source_options.keys())
default_source_label = source_labels[0]
source_default_version = "upload_first_v1"
if (
'video_source_selection' not in st.session_state
or st.session_state['video_source_selection'] not in source_options
):
if st.session_state.get('_video_source_default_version') != source_default_version:
if (
st.session_state.get('video_source_selection') not in source_options
or not st.session_state.get('video_origin_path')
):
st.session_state['video_source_selection'] = default_source_label
st.session_state['_video_source_default_version'] = source_default_version
elif st.session_state.get('video_source_selection') not in source_options:
st.session_state['video_source_selection'] = default_source_label
current_source = st.session_state['video_source_selection']
@ -250,12 +262,13 @@ def render_video_file(tr, params):
)
st.markdown(f"**{tr('Video Source')}** :gray[{source_caption}]")
source = st.selectbox(
source = st.pills(
tr("Video Source"),
options=source_labels,
index=None,
selection_mode="single",
key="video_source_selection",
label_visibility="collapsed",
width="stretch",
)
if not source:
source = default_source_label
@ -399,23 +412,84 @@ def short_drama_summary(tr):
st.session_state['subtitle_file_processed'] = False
render_fun_asr_transcription(tr)
render_subtitle_preview(tr)
# 名称输入框
video_theme = st.text_input(tr("短剧名称"))
current_subtitle_path = st.session_state.get('subtitle_path', '')
plot_analysis_source = st.session_state.get('short_drama_plot_analysis_subtitle_path')
if plot_analysis_source and plot_analysis_source != current_subtitle_path:
st.session_state['short_drama_plot_analysis'] = ""
st.session_state['short_drama_plot_analysis_subtitle_path'] = ""
name_cols = st.columns([4, 1.2], vertical_alignment="bottom")
with name_cols[0]:
video_theme = st.text_input(tr("短剧名称"))
with name_cols[1]:
analyze_plot_clicked = st.button(
tr("剧情理解"),
key="short_drama_plot_analysis_button",
disabled=not current_subtitle_path,
use_container_width=True,
)
st.session_state['video_theme'] = video_theme
# 数字输入框
temperature = st.slider("temperature", 0.0, 2.0, 0.7)
st.session_state['temperature'] = temperature
if analyze_plot_clicked:
with st.spinner(tr("Analyzing plot...")):
plot_analysis = analyze_short_drama_plot(
current_subtitle_path,
st.session_state.get('temperature', 0.7),
tr,
subtitle_content=st.session_state.get('subtitle_content', ''),
)
if plot_analysis:
st.session_state['short_drama_plot_analysis'] = plot_analysis
st.session_state['short_drama_plot_analysis_subtitle_path'] = current_subtitle_path
st.success(tr("Plot analysis completed"))
if st.session_state.get('short_drama_plot_analysis'):
st.text_area(
tr("剧情理解结果"),
key="short_drama_plot_analysis",
height=240,
)
return video_theme
def render_subtitle_preview(tr):
"""渲染可折叠的当前字幕预览;没有字幕时提示用户先转写或上传。"""
subtitle_path = st.session_state.get('subtitle_path', '')
subtitle_content = st.session_state.get('subtitle_content', '')
if subtitle_path and not subtitle_content and os.path.exists(subtitle_path):
subtitle_content = read_subtitle_text(subtitle_path).text
st.session_state['subtitle_content'] = subtitle_content
with st.expander(tr("Subtitle Preview"), expanded=False):
if not subtitle_path or not subtitle_content:
st.info(tr("Please transcribe or upload subtitles first"))
return
st.text_area(
tr("Subtitle Preview"),
key="subtitle_content",
height=180,
label_visibility="collapsed",
)
def render_subtitle_upload(tr):
"""上传并保存用户提供的 SRT 字幕文件。"""
subtitle_dir_label = utils.subtitle_dir().replace(config.root_dir, ".")
st.markdown(
f"**{tr('上传字幕文件')}** "
f":gray[{tr('Transcribed subtitles storage hint').format(path=subtitle_dir_label)}]"
)
subtitle_file = st.file_uploader(
tr("上传字幕文件"),
type=["srt"],
accept_multiple_files=False,
key="subtitle_file_uploader" # 添加唯一key
key="subtitle_file_uploader", # 添加唯一key
label_visibility="collapsed",
)
# 显示当前已上传的字幕文件路径
@ -476,6 +550,141 @@ def render_subtitle_upload(tr):
st.error(f"{tr('Upload failed')}: {str(e)}")
def _is_blank_table_value(value):
if value is None:
return True
if isinstance(value, float) and math.isnan(value):
return True
if isinstance(value, str) and not value.strip():
return True
return False
def _ordered_script_columns(script_rows):
columns = []
for column in SCRIPT_TABLE_BASE_COLUMNS:
columns.append(column)
for row in script_rows:
if not isinstance(row, dict):
continue
for column in row.keys():
if column not in columns:
columns.append(column)
return columns
def _script_json_to_table(script_data):
if not isinstance(script_data, list):
script_data = []
if not script_data:
return pd.DataFrame(columns=SCRIPT_TABLE_BASE_COLUMNS)
if not all(isinstance(item, dict) for item in script_data):
rows = [
{"value": json.dumps(item, ensure_ascii=False)}
for item in script_data
]
return pd.DataFrame(rows, columns=["value"])
columns = _ordered_script_columns(script_data)
return pd.DataFrame(script_data, columns=columns)
def _normalize_script_table_value(column, value):
if _is_blank_table_value(value):
return ""
if column in {"_id", "OST"}:
try:
return int(value)
except (TypeError, ValueError):
return value
return value
def _script_table_to_json(edited_data):
if isinstance(edited_data, pd.DataFrame):
records = edited_data.to_dict("records")
elif isinstance(edited_data, list):
records = edited_data
else:
records = pd.DataFrame(edited_data).to_dict("records")
script_data = []
for row in records:
if not isinstance(row, dict):
continue
if all(_is_blank_table_value(value) for value in row.values()):
continue
cleaned_row = {}
for column, value in row.items():
if not column:
continue
normalized_value = _normalize_script_table_value(column, value)
if _is_blank_table_value(normalized_value) and column not in SCRIPT_TABLE_BASE_COLUMNS:
continue
cleaned_row[column] = normalized_value
if cleaned_row:
script_data.append(cleaned_row)
return json.dumps(script_data, indent=2, ensure_ascii=False)
def render_video_script_editor(tr):
"""使用弹窗和表格编辑视频脚本 JSON。"""
@st.dialog(tr("Video Script"), width="large")
def video_script_dialog():
script_data = st.session_state.get('video_clip_json', [])
table_data = _script_json_to_table(script_data)
column_order = list(table_data.columns)
st.caption(tr("Video script table help"))
edited_table = st.data_editor(
table_data,
key="video_script_table_editor",
hide_index=True,
num_rows="dynamic",
use_container_width=True,
height=520,
row_height=72,
column_order=column_order,
column_config={
"_id": st.column_config.NumberColumn(tr("Script Column ID"), step=1, format="%d", width=52),
"timestamp": st.column_config.TextColumn(tr("Script Column Timestamp"), width=200),
"picture": st.column_config.TextColumn(tr("Script Column Picture"), width=320),
"narration": st.column_config.TextColumn(tr("Script Column Narration"), width=480),
"OST": st.column_config.NumberColumn(
tr("Script Column OST"),
min_value=0,
max_value=2,
step=1,
format="%d",
width=52,
),
},
)
video_clip_json_details = _script_table_to_json(edited_table)
with st.expander(tr("Raw JSON Preview"), expanded=False):
st.code(video_clip_json_details, language="json")
if st.button(tr("Save Script"), key="save_script_from_dialog", use_container_width=True):
save_script_with_validation(tr, video_clip_json_details)
script_data = st.session_state.get('video_clip_json', [])
script_count = len(script_data) if isinstance(script_data, list) else 0
st.markdown(f"**{tr('Video Script')}** :gray[{tr('Video script row count').format(count=script_count)}]")
if st.button(tr("Edit Video Script"), key="open_video_script_editor", use_container_width=True):
video_script_dialog()
def render_fun_asr_transcription(tr):
"""使用 Fun-ASR 从本地音视频转写生成字幕。"""
def clear_fun_asr_subtitle_state():
@ -681,20 +890,22 @@ def render_script_buttons(tr, params):
subtitle_path = st.session_state.get('subtitle_path')
video_theme = st.session_state.get('video_theme')
temperature = st.session_state.get('temperature')
generate_script_short_sunmmary(params, subtitle_path, video_theme, temperature, tr)
plot_analysis = ""
if st.session_state.get('short_drama_plot_analysis_subtitle_path') == subtitle_path:
plot_analysis = st.session_state.get('short_drama_plot_analysis', '')
generate_script_short_sunmmary(
params,
subtitle_path,
video_theme,
temperature,
tr,
plot_analysis=plot_analysis,
subtitle_content=st.session_state.get('subtitle_content', ''),
)
else:
load_script(tr, script_path)
# 视频脚本编辑区
video_clip_json_details = st.text_area(
tr("Video Script"),
value=json.dumps(st.session_state.get('video_clip_json', []), indent=2, ensure_ascii=False),
height=500
)
# 操作按钮行 - 合并格式检查和保存功能
if st.button(tr("Save Script"), key="save_script", use_container_width=True):
save_script_with_validation(tr, video_clip_json_details)
render_video_script_editor(tr)
def load_script(tr, script_path):

View File

@ -9,7 +9,17 @@
"Generate Video Script and Keywords": "Click to use AI to generate **Video Script** and **Video Keywords** based on the **subject**",
"Auto Detect": "Auto Detect",
"Auto Generate": "Frame Analysis",
"Video Script": "Video Script (:blue[①Optional, use AI to generate ②Proper punctuation helps in generating subtitles])",
"Video Script": "Video Script",
"Edit Video Script": "View/Edit Video Script",
"Video script row count": "{count} script rows",
"Video script table help": "Edit the full script JSON as a table. You can add or delete rows; saving will validate and write the script file again.",
"Raw JSON Preview": "Raw JSON Preview",
"Script Column ID": "ID",
"Script Column Timestamp": "Timestamp",
"Script Column Picture": "Picture",
"Script Column Narration": "Narration",
"Script Column OST": "Mark",
"Generation Settings": "Generation Settings",
"Save Script": "Save Script",
"Crop Video": "Crop Video",
"Video File": "Video File",
@ -324,6 +334,13 @@
"Transcribing with Fun-ASR...": "Transcribing subtitles with Ali Bailian Fun-ASR, please wait...",
"Fun-ASR failed without subtitle file": "Fun-ASR transcription failed: no subtitle file was generated",
"Subtitle transcription succeeded": "Subtitle transcription succeeded: {file}",
"Transcribed subtitles storage hint": "Previously transcribed subtitles are saved in {path}; drag a file from that folder to upload",
"剧情理解": "Plot Analysis",
"剧情理解结果": "Plot Analysis Result",
"Analyzing plot...": "Analyzing plot...",
"Plot analysis completed": "Plot analysis completed",
"Please generate or upload subtitles first": "Please transcribe or upload subtitles first",
"Please transcribe or upload subtitles first": "Please transcribe or upload subtitles first",
"Fun-ASR transcription failed": "Fun-ASR transcription failed",
"Validating script format...": "Validating script format...",
"Script format validation failed": "Script format validation failed",

View File

@ -10,6 +10,7 @@
"Auto Detect": "自动检测",
"Video Theme": "视频主题",
"Generation Prompt": "自定义提示词",
"Generation Settings": "生成参数",
"Save Script": "保存脚本",
"Video File": "视频文件",
"Plot Description": "剧情描述 (:blue[可从 https://www.tvmao.com/ 获取])",
@ -104,6 +105,15 @@
"Failed to Save Script": "保存脚本失败",
"Script saved successfully": "脚本保存成功",
"Video Script": "视频脚本",
"Edit Video Script": "查看/编辑视频脚本",
"Video script row count": "共 {count} 条脚本",
"Video script table help": "在表格中编辑完整脚本 JSON。可新增、删除行保存时会重新校验并写入脚本文件。",
"Raw JSON Preview": "原始 JSON 预览",
"Script Column ID": "序号",
"Script Column Timestamp": "时间戳",
"Script Column Picture": "画面描述",
"Script Column Narration": "解说台词",
"Script Column OST": "标记",
"Video Quality": "视频质量",
"Custom prompt for LLM, leave empty to use default prompt": "自定义提示词,留空则使用默认提示词",
"Proxy Settings": "代理设置",
@ -306,6 +316,13 @@
"Transcribing with Fun-ASR...": "正在使用阿里百炼 Fun-ASR 转写字幕,请稍候...",
"Fun-ASR failed without subtitle file": "Fun-ASR 转写失败:未生成字幕文件",
"Subtitle transcription succeeded": "字幕转写成功: {file}",
"Transcribed subtitles storage hint": "之前转录生成的字幕保存在 {path},可从该目录拖入上传",
"剧情理解": "剧情理解",
"剧情理解结果": "剧情理解结果",
"Analyzing plot...": "正在理解剧情...",
"Plot analysis completed": "剧情理解完成",
"Please generate or upload subtitles first": "请先转写或上传字幕",
"Please transcribe or upload subtitles first": "请先转写或上传字幕",
"Fun-ASR transcription failed": "Fun-ASR 字幕转写失败",
"Validating script format...": "正在验证脚本格式...",
"Script format validation failed": "脚本格式验证失败",

View File

@ -135,7 +135,58 @@ def parse_and_fix_json(json_string):
return None
def generate_script_short_sunmmary(params, subtitle_path, video_theme, temperature, tr=lambda key: key):
def analyze_short_drama_plot(subtitle_path, temperature, tr=lambda key: key, subtitle_content=None):
"""仅执行短剧字幕剧情理解,返回可编辑的剧情分析文本。"""
if not subtitle_path:
st.error(tr("Please generate or upload subtitles first"))
return None
if not os.path.exists(subtitle_path):
st.error(tr("Subtitle file does not exist"))
return None
text_provider = config.app.get('text_llm_provider', 'gemini').lower()
text_api_key = config.app.get(f'text_{text_provider}_api_key')
text_model = config.app.get(f'text_{text_provider}_model_name')
text_base_url = config.app.get(f'text_{text_provider}_base_url')
subtitle_content = str(subtitle_content or "").strip() or read_subtitle_text(subtitle_path).text
if not subtitle_content:
st.error(tr("Subtitle file is empty or unreadable"))
return None
try:
logger.info("使用新的LLM服务架构进行字幕分析")
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
analysis_result = analyzer.analyze_subtitle(subtitle_content)
except Exception as e:
logger.warning(f"使用新LLM服务失败回退到旧实现: {str(e)}")
analysis_result = analyze_subtitle(
subtitle_content=subtitle_content,
api_key=text_api_key,
model=text_model,
base_url=text_base_url,
save_result=True,
temperature=temperature,
provider=text_provider
)
if analysis_result["status"] != "success":
logger.error(f"分析失败: {analysis_result['message']}")
st.error(tr("Script generation failed check logs"))
return None
return analysis_result["analysis"]
def generate_script_short_sunmmary(
params,
subtitle_path,
video_theme,
temperature,
tr=lambda key: key,
plot_analysis=None,
subtitle_content=None,
):
"""
生成 短剧解说 视频脚本
要求: 提供高质量短剧字幕
@ -174,30 +225,36 @@ def generate_script_short_sunmmary(params, subtitle_path, video_theme, temperatu
text_base_url = config.app.get(f'text_{text_provider}_base_url')
# 读取字幕文件内容(无论使用哪种实现都需要)
subtitle_content = read_subtitle_text(subtitle_path).text
subtitle_content = str(subtitle_content or "").strip() or read_subtitle_text(subtitle_path).text
if not subtitle_content:
st.error(tr("Subtitle file is empty or unreadable"))
return
try:
# 优先使用新的LLM服务架构
logger.info("使用新的LLM服务架构进行字幕分析")
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
if plot_analysis and str(plot_analysis).strip():
logger.info("使用用户编辑后的剧情理解结果生成解说文案")
analysis_result = {
"status": "success",
"analysis": str(plot_analysis).strip(),
}
else:
try:
# 优先使用新的LLM服务架构
logger.info("使用新的LLM服务架构进行字幕分析")
analysis_result = analyzer.analyze_subtitle(subtitle_content)
analysis_result = analyzer.analyze_subtitle(subtitle_content)
except Exception as e:
logger.warning(f"使用新LLM服务失败回退到旧实现: {str(e)}")
# 回退到旧的实现
analysis_result = analyze_subtitle(
subtitle_file_path=subtitle_path,
api_key=text_api_key,
model=text_model,
base_url=text_base_url,
save_result=True,
temperature=temperature,
provider=text_provider
)
except Exception as e:
logger.warning(f"使用新LLM服务失败回退到旧实现: {str(e)}")
# 回退到旧的实现
analysis_result = analyze_subtitle(
subtitle_content=subtitle_content,
api_key=text_api_key,
model=text_model,
base_url=text_base_url,
save_result=True,
temperature=temperature,
provider=text_provider
)
"""
3. 根据剧情生成解说文案
"""