From e6d15fe24699936778320fad84343afc8db59e5a Mon Sep 17 00:00:00 2001 From: viccy Date: Fri, 5 Jun 2026 19:31:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E6=96=B0=E5=A2=9E=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E5=89=A7=E6=83=85=E5=88=86=E6=9E=90=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E8=84=9A=E6=9C=AC=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E4=B8=8E=E9=80=9A=E7=94=A8=E7=94=9F=E6=88=90=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离通用生成参数设置组件,统一管理temperature等LLM生成参数 - 新增短剧字幕剧情分析功能,支持一键分析与手动编辑分析结果 - 重构短剧脚本生成逻辑,支持传入预先生成的剧情分析内容 - 新增可视化视频脚本表格编辑器,支持增删编辑行与原始JSON预览 - 优化多语言翻译、UI交互细节与字幕相关提示文案 --- webui/components/basic_settings.py | 11 ++ webui/components/script_settings.py | 265 +++++++++++++++++++++++--- webui/i18n/en.json | 19 +- webui/i18n/zh.json | 17 ++ webui/tools/generate_short_summary.py | 97 ++++++++-- 5 files changed, 361 insertions(+), 48 deletions(-) diff --git a/webui/components/basic_settings.py b/webui/components/basic_settings.py index 842a500..39d9904 100644 --- a/webui/components/basic_settings.py +++ b/webui/components/basic_settings.py @@ -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")) diff --git a/webui/components/script_settings.py b/webui/components/script_settings.py index 94a8910..3c91c44 100644 --- a/webui/components/script_settings.py +++ b/webui/components/script_settings.py @@ -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): diff --git a/webui/i18n/en.json b/webui/i18n/en.json index cfa122b..dbce928 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -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", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 602b616..33eb74a 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "脚本格式验证失败", diff --git a/webui/tools/generate_short_summary.py b/webui/tools/generate_short_summary.py index fe2d223..eb42361 100644 --- a/webui/tools/generate_short_summary.py +++ b/webui/tools/generate_short_summary.py @@ -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. 根据剧情生成解说文案 """