mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-08-01 19:05:52 +00:00
feat: 新增脚本自动字幕生成功能并优化网页视频展示
添加script_subtitle服务,支持基于脚本内容自动生成标准SRT字幕文件 修改任务处理流程,优先使用新的脚本字幕生成逻辑,失败时回退至原TTS字幕合并方案 优化最终视频自动转录逻辑,已生成脚本字幕时跳过重复的自动转录步骤 改进网页端弹窗视频的展示样式,根据宽高比调整预览宽度并添加黑色背景 新增完整的单元测试覆盖字幕生成相关功能
This commit is contained in:
parent
4ab29fd776
commit
ca4f2bf594
213
app/services/script_subtitle.py
Normal file
213
app/services/script_subtitle.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.utils import utils
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SUBTITLE_OST_TYPES = (0, 2)
|
||||||
|
DEFAULT_MAX_CHARS_PER_SUBTITLE = 12
|
||||||
|
SENTENCE_PART_RE = re.compile(r"[^。!?!?;;,,、\n]+[。!?!?;;,,、]?")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(text or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_punctuation(text: str) -> str:
|
||||||
|
return "".join(
|
||||||
|
char for char in str(text or "")
|
||||||
|
if not unicodedata.category(char).startswith("P")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_subtitle_text(text: str) -> str:
|
||||||
|
"""Normalize subtitle text for burn-in display."""
|
||||||
|
return _normalize_text(_remove_punctuation(text))
|
||||||
|
|
||||||
|
|
||||||
|
def split_narration(text: str, max_chars: int = DEFAULT_MAX_CHARS_PER_SUBTITLE) -> List[str]:
|
||||||
|
"""Split narration into readable subtitle chunks."""
|
||||||
|
text = _normalize_text(text)
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_chars = max(1, int(max_chars or DEFAULT_MAX_CHARS_PER_SUBTITLE))
|
||||||
|
parts = [match.group(0).strip() for match in SENTENCE_PART_RE.finditer(text)]
|
||||||
|
if not parts:
|
||||||
|
parts = [text]
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
def flush_long_part(part: str) -> str:
|
||||||
|
while len(part) > max_chars:
|
||||||
|
chunks.append(part[:max_chars].strip())
|
||||||
|
part = part[max_chars:].strip()
|
||||||
|
return part
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(part) > max_chars:
|
||||||
|
if current:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
current = ""
|
||||||
|
current = flush_long_part(part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = f"{current}{part}" if current else part
|
||||||
|
if len(candidate) <= max_chars:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
current = part
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
|
||||||
|
return [cleaned for chunk in chunks if (cleaned := clean_subtitle_text(chunk))]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt_like_time(time_text: str) -> float:
|
||||||
|
time_text = str(time_text or "").strip().replace(",", ".")
|
||||||
|
parts = time_text.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"不支持的时间格式: {time_text}")
|
||||||
|
|
||||||
|
hours = int(parts[0])
|
||||||
|
minutes = int(parts[1])
|
||||||
|
seconds = float(parts[2])
|
||||||
|
return hours * 3600 + minutes * 60 + seconds
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_range(time_range: str) -> Tuple[float, float]:
|
||||||
|
if not time_range or "-" not in str(time_range):
|
||||||
|
raise ValueError(f"不支持的时间范围: {time_range}")
|
||||||
|
|
||||||
|
start_text, end_text = str(time_range).split("-", 1)
|
||||||
|
start = parse_srt_like_time(start_text)
|
||||||
|
end = parse_srt_like_time(end_text)
|
||||||
|
if end <= start:
|
||||||
|
raise ValueError(f"结束时间必须晚于开始时间: {time_range}")
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def format_srt_time(seconds: float) -> str:
|
||||||
|
milliseconds_total = max(0, int(round(float(seconds) * 1000)))
|
||||||
|
milliseconds = milliseconds_total % 1000
|
||||||
|
total_seconds = milliseconds_total // 1000
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ost_value(value) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_item_time_range(item: dict, current_time: float) -> Tuple[Optional[Tuple[float, float]], float]:
|
||||||
|
edited_time_range = item.get("editedTimeRange")
|
||||||
|
if edited_time_range:
|
||||||
|
try:
|
||||||
|
start, end = parse_time_range(edited_time_range)
|
||||||
|
return (start, end), end
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"解析 editedTimeRange 失败,将尝试使用 duration: {e}")
|
||||||
|
|
||||||
|
duration = float(item.get("duration", 0.0) or 0.0)
|
||||||
|
if duration <= 0:
|
||||||
|
return None, current_time
|
||||||
|
|
||||||
|
start = current_time
|
||||||
|
end = current_time + duration
|
||||||
|
return (start, end), end
|
||||||
|
|
||||||
|
|
||||||
|
def _build_srt_blocks(
|
||||||
|
list_script: Sequence[dict],
|
||||||
|
include_ost: Iterable[int],
|
||||||
|
max_chars: int,
|
||||||
|
) -> List[str]:
|
||||||
|
include_ost_set = {int(item) for item in include_ost}
|
||||||
|
blocks = []
|
||||||
|
subtitle_index = 1
|
||||||
|
current_time = 0.0
|
||||||
|
|
||||||
|
for item in list_script:
|
||||||
|
time_range, current_time = _resolve_item_time_range(item, current_time)
|
||||||
|
if not time_range:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ost = _safe_ost_value(item.get("OST"))
|
||||||
|
if ost not in include_ost_set:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunks = split_narration(item.get("narration", ""), max_chars=max_chars)
|
||||||
|
if not chunks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start, end = time_range
|
||||||
|
segment_duration = end - start
|
||||||
|
if segment_duration <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunk_duration = segment_duration / len(chunks)
|
||||||
|
for chunk_index, chunk in enumerate(chunks):
|
||||||
|
chunk_start = start + chunk_duration * chunk_index
|
||||||
|
chunk_end = end if chunk_index == len(chunks) - 1 else start + chunk_duration * (chunk_index + 1)
|
||||||
|
blocks.append(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
str(subtitle_index),
|
||||||
|
f"{format_srt_time(chunk_start)} --> {format_srt_time(chunk_end)}",
|
||||||
|
chunk,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
subtitle_index += 1
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def create_script_subtitle_file(
|
||||||
|
task_id: str,
|
||||||
|
list_script: Sequence[dict],
|
||||||
|
output_file: Optional[str] = None,
|
||||||
|
include_ost: Optional[Iterable[int]] = None,
|
||||||
|
max_chars: int = DEFAULT_MAX_CHARS_PER_SUBTITLE,
|
||||||
|
) -> str:
|
||||||
|
"""Create a full SRT file from script narration and edited timeline ranges."""
|
||||||
|
if not list_script:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if include_ost is None:
|
||||||
|
include_ost = DEFAULT_SUBTITLE_OST_TYPES
|
||||||
|
|
||||||
|
blocks = _build_srt_blocks(list_script, include_ost=include_ost, max_chars=max_chars)
|
||||||
|
if not blocks:
|
||||||
|
logger.warning("程序化字幕未生成内容")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if output_file is None:
|
||||||
|
output_file = os.path.join(utils.task_dir(task_id), "script_subtitles.srt")
|
||||||
|
|
||||||
|
output_dir = os.path.dirname(output_file)
|
||||||
|
if output_dir:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n\n".join(blocks))
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
logger.info(f"程序化字幕生成成功: {output_file}, 共 {len(blocks)} 条")
|
||||||
|
return output_file
|
||||||
@ -10,7 +10,16 @@ from app.config import config
|
|||||||
from app.config.audio_config import AudioConfig, get_recommended_volumes_for_content
|
from app.config.audio_config import AudioConfig, get_recommended_volumes_for_content
|
||||||
from app.models import const
|
from app.models import const
|
||||||
from app.models.schema import VideoClipParams
|
from app.models.schema import VideoClipParams
|
||||||
from app.services import (voice, audio_merger, subtitle_merger, clip_video, merger_video, update_script, generate_video)
|
from app.services import (
|
||||||
|
voice,
|
||||||
|
audio_merger,
|
||||||
|
subtitle_merger,
|
||||||
|
clip_video,
|
||||||
|
merger_video,
|
||||||
|
update_script,
|
||||||
|
generate_video,
|
||||||
|
script_subtitle,
|
||||||
|
)
|
||||||
from app.services import state as sm
|
from app.services import state as sm
|
||||||
from app.utils import utils
|
from app.utils import utils
|
||||||
|
|
||||||
@ -561,8 +570,20 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
|||||||
)
|
)
|
||||||
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
||||||
|
|
||||||
# 合并字幕文件
|
# 优先基于脚本文案和成片时间线生成字幕,失败时回退到TTS字幕合并
|
||||||
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
merged_subtitle_path = ""
|
||||||
|
if getattr(params, "subtitle_enabled", True):
|
||||||
|
try:
|
||||||
|
merged_subtitle_path = script_subtitle.create_script_subtitle_file(
|
||||||
|
task_id=task_id,
|
||||||
|
list_script=new_script_list,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"程序化字幕生成失败,将尝试合并TTS字幕: {e}")
|
||||||
|
|
||||||
|
if not merged_subtitle_path and getattr(params, "subtitle_enabled", True):
|
||||||
|
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
||||||
|
|
||||||
if merged_subtitle_path:
|
if merged_subtitle_path:
|
||||||
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||||
else:
|
else:
|
||||||
@ -630,7 +651,9 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
|||||||
6. 合并字幕/BGM/配音/视频
|
6. 合并字幕/BGM/配音/视频
|
||||||
"""
|
"""
|
||||||
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
|
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
|
||||||
auto_transcription_enabled = _is_auto_transcription_enabled(params)
|
auto_transcription_enabled = _is_auto_transcription_enabled(params) and not bool(merged_subtitle_path)
|
||||||
|
if _is_auto_transcription_enabled(params) and merged_subtitle_path:
|
||||||
|
logger.info("已生成字幕文件,跳过最终视频自动转录")
|
||||||
merge_output_video_path = (
|
merge_output_video_path = (
|
||||||
path.join(utils.task_dir(task_id), "combined_without_auto_subtitles.mp4")
|
path.join(utils.task_dir(task_id), "combined_without_auto_subtitles.mp4")
|
||||||
if auto_transcription_enabled
|
if auto_transcription_enabled
|
||||||
|
|||||||
94
app/services/test_script_subtitle_unittest.py
Normal file
94
app/services/test_script_subtitle_unittest.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.services import script_subtitle
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptSubtitleTests(unittest.TestCase):
|
||||||
|
def test_split_narration_prefers_punctuation_boundaries(self):
|
||||||
|
chunks = script_subtitle.split_narration(
|
||||||
|
"她终于意识到,这场婚姻不是爱情,而是一场交易。",
|
||||||
|
max_chars=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["她终于意识到", "这场婚姻不是爱情", "而是一场交易"],
|
||||||
|
chunks,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_time_range_parsing_supports_milliseconds(self):
|
||||||
|
start, end = script_subtitle.parse_time_range("00:00:01,500-00:00:03,250")
|
||||||
|
|
||||||
|
self.assertAlmostEqual(1.5, start)
|
||||||
|
self.assertAlmostEqual(3.25, end)
|
||||||
|
|
||||||
|
def test_create_script_subtitle_file_skips_original_audio_segments(self):
|
||||||
|
list_script = [
|
||||||
|
{
|
||||||
|
"_id": 1,
|
||||||
|
"OST": 0,
|
||||||
|
"narration": "第一句解说。第二句解说。",
|
||||||
|
"editedTimeRange": "00:00:00-00:00:04",
|
||||||
|
"duration": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": 2,
|
||||||
|
"OST": 1,
|
||||||
|
"narration": "这句是原声,不应该默认生成。",
|
||||||
|
"editedTimeRange": "00:00:04-00:00:08",
|
||||||
|
"duration": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": 3,
|
||||||
|
"OST": 2,
|
||||||
|
"narration": "混合片段也保留解说字幕。",
|
||||||
|
"editedTimeRange": "00:00:08-00:00:12",
|
||||||
|
"duration": 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_file = Path(temp_dir) / "script_subtitles.srt"
|
||||||
|
result = script_subtitle.create_script_subtitle_file(
|
||||||
|
task_id="test",
|
||||||
|
list_script=list_script,
|
||||||
|
output_file=str(output_file),
|
||||||
|
max_chars=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(str(output_file), result)
|
||||||
|
content = output_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("00:00:00,000 -->", content)
|
||||||
|
self.assertIn("第一句解说", content)
|
||||||
|
self.assertIn("混合片段也保留解说字幕", content)
|
||||||
|
self.assertNotIn("这句是原声", content)
|
||||||
|
self.assertNotIn("。", content)
|
||||||
|
self.assertNotIn(",", content)
|
||||||
|
|
||||||
|
def test_create_script_subtitle_file_uses_duration_when_edited_range_missing(self):
|
||||||
|
list_script = [
|
||||||
|
{
|
||||||
|
"_id": 1,
|
||||||
|
"OST": 0,
|
||||||
|
"narration": "没有 editedTimeRange 时使用 duration。",
|
||||||
|
"duration": 3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_file = Path(temp_dir) / "script_subtitles.srt"
|
||||||
|
script_subtitle.create_script_subtitle_file(
|
||||||
|
task_id="test",
|
||||||
|
list_script=list_script,
|
||||||
|
output_file=str(output_file),
|
||||||
|
)
|
||||||
|
content = output_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("00:00:00,000 -->", content)
|
||||||
|
self.assertIn("--> 00:00:03,000", content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
15
webui.py
15
webui.py
@ -181,6 +181,11 @@ def render_generate_button():
|
|||||||
div[data-testid="stDialog"] div[data-testid="stProgress"] {
|
div[data-testid="stDialog"] div[data-testid="stProgress"] {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
div[data-testid="stDialog"] video {
|
||||||
|
max-height: 62vh;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@ -246,8 +251,16 @@ def render_generate_button():
|
|||||||
video_files = task.get("videos", [])
|
video_files = task.get("videos", [])
|
||||||
try:
|
try:
|
||||||
if video_files:
|
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:
|
for url in video_files:
|
||||||
st.video(url)
|
_, preview_col, _ = st.columns([1, 2, 1])
|
||||||
|
with preview_col:
|
||||||
|
st.video(url, width=preview_width)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"播放视频失败: {e}")
|
logger.error(f"播放视频失败: {e}")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user