mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-06-30 03:15:16 +00:00
- 新增短剧解说全流程四类提示词模板:解说文案生成、片段规划、文案画面匹配、脚本修复 - 重构原有脚本生成提示词至v2.1,改为基于上游规划片段生成合规解说脚本 - 为LLM基础服务层新增流式文本生成接口,完善OpenAI兼容提供商的流式实现,支持流式回调与推理内容提取 - 重构OpenAI兼容文本提供商的生成逻辑,提取公共参数构建方法 - 新增多语言国际化文案,覆盖解说语言、短剧类型、原片占比等配置项与交互提示 - 新增多套单元测试,覆盖脚本校验、适配器流程、工具函数等模块 - 封装SubtitleAnalyzerAdapter,统一短剧解说脚本生成的整套业务接口 - 新增前端交互所需的解说文案审核相关提示文案
291 lines
9.5 KiB
Python
291 lines
9.5 KiB
Python
import unittest
|
|
|
|
from app.services.short_drama_narration_validation import (
|
|
build_subtitle_index,
|
|
normalize_script_video_sources,
|
|
validate_narration_script_items,
|
|
)
|
|
|
|
|
|
SUBTITLE_CONTENT = """# 视频 1: first.mp4
|
|
字幕文件: first.srt
|
|
1
|
|
00:00:01,000 --> 00:00:04,000
|
|
女主被众人误会。
|
|
|
|
2
|
|
00:00:04,000 --> 00:00:08,000
|
|
男主冷眼看着她。
|
|
|
|
# 视频 2: second.mp4
|
|
字幕文件: second.srt
|
|
1
|
|
00:00:02,000 --> 00:00:05,000
|
|
女主终于拿出证据。
|
|
|
|
2
|
|
00:00:05,000 --> 00:00:09,000
|
|
众人震惊,反派慌了。
|
|
"""
|
|
|
|
|
|
class ShortDramaNarrationValidationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.video_paths = ["/tmp/first.mp4", "/tmp/second.mp4"]
|
|
self.subtitle_index = build_subtitle_index(SUBTITLE_CONTENT, self.video_paths)
|
|
|
|
def test_build_subtitle_index_preserves_multi_video_sources(self):
|
|
self.assertEqual(4, len(self.subtitle_index))
|
|
self.assertEqual({1, 2}, {cue.video_id for cue in self.subtitle_index})
|
|
self.assertEqual("first.mp4", self.subtitle_index[0].video_name)
|
|
self.assertEqual("second.mp4", self.subtitle_index[2].video_name)
|
|
self.assertEqual("00:00:02,000-00:00:05,000", self.subtitle_index[2].timestamp)
|
|
|
|
def test_valid_script_passes_and_normalizes_video_name(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "wrong-name.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "她被当众误会。",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 2,
|
|
"video_name": "second.mp4",
|
|
"timestamp": "00:00:02,000-00:00:05,000",
|
|
"picture": "女主拿出证据",
|
|
"narration": "播放原片2",
|
|
"OST": 1,
|
|
},
|
|
]
|
|
|
|
normalized = normalize_script_video_sources(items, self.video_paths)
|
|
result = validate_narration_script_items(normalized, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertTrue(result.valid, result.errors)
|
|
self.assertEqual(2, result.items[1]["video_id"])
|
|
self.assertEqual("second.mp4", result.items[1]["video_name"])
|
|
|
|
def test_invalid_timestamp_and_overlap_fail(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:05,000",
|
|
"picture": "画面",
|
|
"narration": "解说",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 2,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:04,500-00:00:08,000",
|
|
"picture": "画面",
|
|
"narration": "解说",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 3,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "bad",
|
|
"picture": "画面",
|
|
"narration": "解说",
|
|
"OST": 0,
|
|
},
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("重叠" in error for error in result.errors))
|
|
self.assertTrue(any("时间戳格式" in error for error in result.errors))
|
|
|
|
def test_invalid_video_id_does_not_default_to_first_video(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 99,
|
|
"video_name": "missing.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "画面",
|
|
"narration": "解说",
|
|
"OST": 0,
|
|
}
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("video_id=99" in error for error in result.errors))
|
|
|
|
def test_out_of_range_timestamp_fails(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 2,
|
|
"video_name": "second.mp4",
|
|
"timestamp": "00:00:20,000-00:00:25,000",
|
|
"picture": "画面",
|
|
"narration": "解说",
|
|
"OST": 0,
|
|
}
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("不在视频 2 的字幕范围内" in error for error in result.errors))
|
|
|
|
def test_three_consecutive_original_audio_segments_fail(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "她被当众误会。",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 2,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:04,000-00:00:05,000",
|
|
"picture": "男主看着她",
|
|
"narration": "播放原片2",
|
|
"OST": 1,
|
|
},
|
|
{
|
|
"_id": 3,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:05,000-00:00:06,000",
|
|
"picture": "男主看着她",
|
|
"narration": "播放原片3",
|
|
"OST": 1,
|
|
},
|
|
{
|
|
"_id": 4,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:06,000-00:00:08,000",
|
|
"picture": "男主继续观察",
|
|
"narration": "播放原片4",
|
|
"OST": 1,
|
|
},
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("连续原声过多" in error for error in result.errors))
|
|
|
|
def test_cross_video_original_audio_requires_narration_bridge(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "她被当众误会。",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 2,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:04,000-00:00:08,000",
|
|
"picture": "男主看着她",
|
|
"narration": "播放原片2",
|
|
"OST": 1,
|
|
},
|
|
{
|
|
"_id": 3,
|
|
"video_id": 2,
|
|
"video_name": "second.mp4",
|
|
"timestamp": "00:00:02,000-00:00:05,000",
|
|
"picture": "女主拿出证据",
|
|
"narration": "播放原片3",
|
|
"OST": 1,
|
|
},
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("跨视频切换缺少 OST=0 解说桥段" in error for error in result.errors))
|
|
|
|
def test_cross_video_switch_with_narration_bridge_passes(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "她被当众误会。",
|
|
"OST": 0,
|
|
},
|
|
{
|
|
"_id": 2,
|
|
"video_id": 2,
|
|
"video_name": "second.mp4",
|
|
"timestamp": "00:00:02,000-00:00:05,000",
|
|
"picture": "女主拿出证据",
|
|
"narration": "播放原片2",
|
|
"OST": 1,
|
|
},
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertTrue(result.valid, result.errors)
|
|
|
|
def test_first_segment_must_be_narration_hook(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "播放原片1",
|
|
"OST": 1,
|
|
}
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("解说开场钩子" in error for error in result.errors))
|
|
|
|
def test_dense_narration_fails_when_video_duration_is_too_short(self):
|
|
items = [
|
|
{
|
|
"_id": 1,
|
|
"video_id": 1,
|
|
"video_name": "first.mp4",
|
|
"timestamp": "00:00:01,000-00:00:04,000",
|
|
"picture": "女主被误会",
|
|
"narration": "她明明什么都没做却被所有人推到风口浪尖只能独自承受委屈",
|
|
"OST": 0,
|
|
}
|
|
]
|
|
|
|
result = validate_narration_script_items(items, self.subtitle_index, self.video_paths)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("解说过密" in error for error in result.errors))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|