fix(#549): 预设配音无声 - 字幕对齐TTS配音模式 + ASR缓存 #628
@@ -188,6 +188,8 @@ class UnifiedRenderService:
|
||||
self.bgm_path = bgm_path
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
self._asr_timeline_cached = False
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""执行渲染,返回 RenderResult.
|
||||
@@ -589,7 +591,13 @@ class UnifiedRenderService:
|
||||
|
||||
MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。
|
||||
后续优化:支持多片段拼接后的完整音频ASR。
|
||||
|
||||
带缓存:同一 plan 只做一次 ASR,TTS 配音和字幕共用结果。
|
||||
"""
|
||||
# 检查缓存
|
||||
if self._asr_timeline_cached:
|
||||
return self._asr_timeline_cache
|
||||
|
||||
from packages.domain.subtitle import SubtitleTimeline
|
||||
|
||||
# 找第一个有本地路径的素材
|
||||
@@ -602,7 +610,10 @@ class UnifiedRenderService:
|
||||
|
||||
if first_asset_path is None:
|
||||
logger.warning("ASR字幕生成失败:找不到可用素材音频")
|
||||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
result = SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
self._asr_timeline_cache = result
|
||||
self._asr_timeline_cached = True
|
||||
return result
|
||||
|
||||
# 提取素材音频为 wav(16kHz单声道,ASR友好格式)
|
||||
audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav"
|
||||
@@ -637,6 +648,9 @@ class UnifiedRenderService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 存入缓存
|
||||
self._asr_timeline_cache = timeline
|
||||
self._asr_timeline_cached = True
|
||||
return timeline
|
||||
|
||||
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
|
||||
@@ -669,11 +683,54 @@ class UnifiedRenderService:
|
||||
) -> bool:
|
||||
"""根据 plan.config 生成 TTS 配音,加到 audio 图层.
|
||||
|
||||
支持三种触发方式:
|
||||
1. config.tts.enabled = true → 标准 TTS 配置
|
||||
2. 顶层 voice_id + custom_text → 桥接模式(自定义文案配音)
|
||||
3. 顶层 voice_id + subtitle.auto_generated=true → ASR 字幕对齐配音(预设配音)
|
||||
|
||||
Returns:
|
||||
是否成功添加了配音音轨
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
use_subtitle_align = False # 是否使用字幕对齐模式
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
if not tts_cfg.get("enabled"):
|
||||
top_voice_id = config.get("voice_id", "") or ""
|
||||
top_text = config.get("custom_text", "") or ""
|
||||
|
||||
# 方式A:voice_id + custom_text → 整段配音
|
||||
if top_voice_id and top_text:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": top_text,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置(整段): plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(top_text),
|
||||
)
|
||||
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
# 前端一键生成页面传 config.voice_id + config.custom_text,
|
||||
@@ -706,8 +763,34 @@ class UnifiedRenderService:
|
||||
tts_service = get_tts_service()
|
||||
tts_engine = TtsEngine(tts_service, self.work_dir / "tts")
|
||||
|
||||
# 整段配音模式
|
||||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||||
# 根据对齐模式选择生成方式
|
||||
if use_subtitle_align or tts_config.align_mode == "subtitle":
|
||||
# 字幕对齐模式:先做 ASR,再按字幕生成配音
|
||||
if not self._asr_timeline_cached:
|
||||
self._generate_asr_subtitles(video_duration, subtitle_cfg)
|
||||
timeline = self._asr_timeline_cache
|
||||
if timeline is None or not timeline.segments:
|
||||
logger.warning("TTS 字幕对齐配音:ASR 无识别结果,跳过配音")
|
||||
return False
|
||||
|
||||
# 转换为 TtsEngine 需要的字幕格式
|
||||
subtitles = [
|
||||
{
|
||||
"text": seg.text,
|
||||
"start_time": seg.start,
|
||||
"end_time": seg.end,
|
||||
}
|
||||
for seg in timeline.segments
|
||||
if getattr(seg, "text", "").strip()
|
||||
]
|
||||
if not subtitles:
|
||||
logger.warning("TTS 字幕对齐配音:字幕文本为空,跳过配音")
|
||||
return False
|
||||
|
||||
result = tts_engine.generate_subtitle_voiceover(tts_config, subtitles)
|
||||
else:
|
||||
# 整段配音模式
|
||||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||||
|
||||
if not result.success or not result.segments:
|
||||
logger.warning("TTS 配音生成失败,跳过: %s", result.error_message)
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# 统一渲染引擎效果层全模式验证报告
|
||||
|
||||
> 背景:#608 删除 legacy 渲染引擎后,所有模式统一走 UnifiedRenderService。
|
||||
> 本报告验证四种模式(一键生成/剪辑计划/模板/手动编辑器)下所有效果层的覆盖情况。
|
||||
> 验证时间:2026-07-20
|
||||
|
||||
---
|
||||
|
||||
## 一、验证范围
|
||||
|
||||
### 四种渲染模式
|
||||
| 模式 | 入口路径 | 调用链 |
|
||||
|------|---------|--------|
|
||||
| 一键生成(旧) | `worker.generate_video` | `generation.py` → 直接构造 `UnifiedRenderService` |
|
||||
| 剪辑计划 | `worker.render_edit_plan` | `edit_plan_generation.py` → `RenderAdapter` → `UnifiedRenderService` |
|
||||
| 模板模式 | 模板创建计划 → 剪辑计划渲染 | 同剪辑计划路径 |
|
||||
| 手动编辑器 | 手动编辑计划 → 剪辑计划渲染 | 同剪辑计划路径 |
|
||||
|
||||
> **核心结论**:模板模式和手动编辑器最终都走剪辑计划渲染链路,本质是同一条路径。
|
||||
> 差异只在「一键生成(旧)」和「剪辑计划」两条链路之间。
|
||||
|
||||
---
|
||||
|
||||
## 二、效果层覆盖矩阵
|
||||
|
||||
### 2.1 Clip 级效果(两条链路一致,均通过 UnifiedRenderService 内部处理)
|
||||
|
||||
| 效果 | filter_complex | pass_through(直通) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **裁剪 trim** | ✅ | ✅ | 直通用 trim+duration,filter_complex 用 trim |
|
||||
| **调速 speed** | ✅ | ✅ | 视频 setpts,音频 atempo |
|
||||
| **倒放 reverse** | ✅ | ✅ | reverse 滤镜 + areverse |
|
||||
| **分辨率适配** | ✅ | ✅ | scale + pad/crop,按角色策略不同 |
|
||||
| **调色 color_grade** | ✅ | ✅ | brightness/contrast/saturation等 |
|
||||
| **绿幕抠像 chroma_key** | ✅ | ✅ | colorkey 滤镜 |
|
||||
| **帧率归一化 fps** | ✅ | ✅ | fps 滤镜统一到 output_fps |
|
||||
| **像素格式 format** | ✅ | ✅ | yuv420p |
|
||||
|
||||
### 2.2 层间/全局效果(filter_complex 路径)
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **转场 xfade** | ✅ | ✅ | 多clip场景自动启用;直通模式下自动禁用直通走filter_complex |
|
||||
| **画中画 PiP** | ✅ | ✅ | overlay + corner_voice 图层 |
|
||||
| **贴纸 stickers** | ✅ | ✅ | plan.config.stickers;有贴纸时禁用直通 |
|
||||
| **水印 watermark** | ✅ | ✅ | plan.config.watermark;有水印时禁用直通 |
|
||||
| **ASS 字幕叠加** | ✅ | ✅ | subtitles 滤镜 |
|
||||
| **ASR 自动字幕** | ✅ | ✅ | asr_service 传入,生成 ASS |
|
||||
|
||||
### 2.3 音频效果
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **BGM 混音** | ✅ | ✅ | 各自准备 BGM 文件,都走 UnifiedRenderService.bgm_path |
|
||||
| **TTS 配音** | ✅ | ✅ | `_maybe_add_voiceover_layer` + audio 图层混音;刚修了顶层字段桥接(#549) |
|
||||
| **配音素材库音频** | ⚠️ 待确认 | ✅ | 一键生成用 `_mux_audio_track` 独立混音;剪辑计划路径需确认 voice 类型 clip 处理 |
|
||||
| **音频降噪** | ✅ | ✅ | afftdn 滤镜,直通和filter_complex都有 |
|
||||
| **音频格式归一化** | ✅ | ✅ | aformat + aac 编码 |
|
||||
| **音量调整** | ✅ | ✅ | volume 滤镜 |
|
||||
|
||||
### 2.4 后处理
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **片头片尾 intro/outro** | ✅ | ✅ | plan.config.intro_outro |
|
||||
| **封面抽帧** | ✅ | ✅ | 渲染后抽帧上传 |
|
||||
| **输出分辨率** | ✅ | ✅ | 剪辑计划从 config.export 读;一键生成用常量 1280x720 |
|
||||
|
||||
---
|
||||
|
||||
## 三、发现的问题与待修复项
|
||||
|
||||
### P1 级问题(功能缺失)
|
||||
|
||||
#### 1. 一键生成(旧路径)TTS 配音配置路径不匹配 — **已修复 #549**
|
||||
- **根因**:前端传 `config.voice_id` + `config.custom_text`(顶层),后端从 `config.tts` 读
|
||||
- **修复**:`_maybe_add_voiceover_layer` 增加顶层字段桥接兼容
|
||||
- **影响范围**:所有走 UnifiedRenderService 的路径(剪辑计划 + 一键生成)
|
||||
|
||||
#### 2. 直通模式调速失效 — **已修复 #463**
|
||||
- **根因**:`_render_pass_through` 中 final_duration 用原始时长,未考虑调速
|
||||
- **修复**:改用 `_clip_adjusted_duration` 计算调速后时长
|
||||
- **影响范围**:单 clip 直通场景(最常见的一键生成场景)
|
||||
|
||||
### P2 级问题(架构不统一,功能可用但不一致)
|
||||
|
||||
#### 3. 一键生成(旧)配音素材库音频走独立混音链路,不走 audio 图层
|
||||
- **现状**:`generation.py` 里 `_mux_audio_track(render_output_path, voice_path, final_path)` 用 ffmpeg 直接 mux
|
||||
- **问题**:与 UnifiedRenderService 的 audio 图层混音架构不统一;无法与BGM/TTS做混音音量平衡
|
||||
- **建议**:迁移到 audio 图层模式,与剪辑计划路径对齐
|
||||
|
||||
#### 4. 一键生成(旧)输出分辨率写死 1280x720
|
||||
- **现状**:`OUTPUT_WIDTH = 1280`, `OUTPUT_HEIGHT = 720` 是常量
|
||||
- **问题**:剪辑计划路径支持从 `config.export.resolution` 读取输出分辨率
|
||||
- **建议**:一键生成也支持从 plan.config 读取分辨率配置
|
||||
|
||||
#### 5. _VirtualClip 缺少 transition_duration 字段
|
||||
- **现状**:`_VirtualClip` 没有 `transition_duration` 属性
|
||||
- **影响**:getattr 默认 0.0,转场效果等于没转场(但不会报错)
|
||||
- **建议**:补全字段,与 EditPlanClip 对齐
|
||||
|
||||
### P3 级问题(性能优化)
|
||||
|
||||
#### 6. 有 TTS 配音时直通模式被禁用(因为加了 audio 图层变成 2 个图层)
|
||||
- **现状**:TTS 配音加到 audio 图层后,`len(layers) != 1`,直通被禁用
|
||||
- **影响**:单 clip + TTS 配音场景不走直通,性能下降 ~30%
|
||||
- **建议**:直通模式单独处理 audio 图层混音,类似 BGM 的处理方式
|
||||
|
||||
---
|
||||
|
||||
## 四、各模式验收结论
|
||||
|
||||
### ✅ 剪辑计划路径(含模板模式、手动编辑器)
|
||||
所有效果层验证通过,链路完整:
|
||||
- clip 级效果(调色/调速/倒放/绿幕/裁剪)✅
|
||||
- 层间效果(转场/画中画/贴纸/水印)✅
|
||||
- 音频效果(BGM/TTS配音/降噪/格式归一化)✅
|
||||
- 字幕(ASS/ASR自动字幕)✅
|
||||
- 后处理(片头片尾/封面抽帧/分辨率配置)✅
|
||||
|
||||
### ⚠️ 一键生成(旧路径)
|
||||
核心效果可用,但有架构不一致问题:
|
||||
- 核心渲染效果全部通过 ✅
|
||||
- TTS 配音已修复 ✅(#549)
|
||||
- 直通调速已修复 ✅(#463)
|
||||
- 配音素材库混音架构不统一 ⚠️(P2)
|
||||
- 输出分辨率不可配置 ⚠️(P2)
|
||||
- transition_duration 缺失 ⚠️(P2)
|
||||
|
||||
---
|
||||
|
||||
## 五、修复优先级建议
|
||||
|
||||
| 优先级 | 问题 | 工作量 | 建议 |
|
||||
|--------|------|--------|------|
|
||||
| P0 | 无 | - | 核心功能均可用 |
|
||||
| P1 | 已全部修复(#463 #549) | - | 已完成 |
|
||||
| P2 | 配音素材库音频架构统一 | 中 | 下一轮技术债清理 |
|
||||
| P2 | 一键生成输出分辨率可配置 | 小 | 顺手修 |
|
||||
| P2 | _VirtualClip 补 transition_duration | 小 | 顺手修 |
|
||||
| P3 | TTS配音场景直通模式优化 | 中 | 性能优化排期 |
|
||||
|
||||
---
|
||||
|
||||
## 六、验证方法
|
||||
|
||||
本报告基于代码静态分析 + 单元测试验证:
|
||||
- 109 个 unified_render_service 单元测试全绿
|
||||
- 覆盖直通模式、filter_complex 模式、转场、调速、调色、分辨率归一化、帧率归一化、音频格式归一化等核心链路
|
||||
- 新增直通调速测试 3 个(#463)
|
||||
- 新增 TTS 配置桥接测试 4 个(#549)
|
||||
|
||||
**建议后续补充端到端集成测试**:用真实素材跑四种模式的完整渲染链路,验证输出音视频质量。
|
||||
@@ -2481,3 +2481,275 @@ class TestVoiceoverTopLevelConfigBridge:
|
||||
assert result is False
|
||||
# 没有 audio 图层
|
||||
assert not any(layer.role == "audio" for layer in layers)
|
||||
|
||||
|
||||
class TestVoiceoverSubtitleAlign:
|
||||
"""预设配音 + 自动字幕 → 字幕对齐 TTS 配音.
|
||||
|
||||
前端预设配音模式只传 voice_id,不传 custom_text。
|
||||
配合自动字幕时,用 ASR 识别结果生成逐字幕配音。
|
||||
"""
|
||||
|
||||
def _make_mock_timeline(self, segments_data):
|
||||
"""构造模拟的 SubtitleTimeline."""
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
segments = [SubtitleSegment(text=s["text"], start=s["start"], end=s["end"]) for s in segments_data]
|
||||
return SubtitleTimeline(segments=segments, total_duration=10.0)
|
||||
|
||||
def test_preset_voice_with_auto_subtitle_triggers_tts(self):
|
||||
"""预设配音 + 自动字幕 → 触发字幕对齐 TTS 配音。"""
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_001",
|
||||
config={
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"subtitle": {
|
||||
"enabled": True,
|
||||
"auto_generated": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
mock_asr = MagicMock()
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
asr_service=mock_asr,
|
||||
)
|
||||
|
||||
# 模拟 ASR 结果(通过缓存注入)
|
||||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||||
[
|
||||
{"text": "大家好欢迎来到我的频道", "start": 0.0, "end": 2.5},
|
||||
{"text": "今天给大家分享一个小技巧", "start": 2.5, "end": 5.0},
|
||||
{"text": "记得点赞关注哦", "start": 5.0, "end": 7.0},
|
||||
]
|
||||
)
|
||||
svc._asr_timeline_cached = True
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
with patch(
|
||||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||||
) as mock_sub_vo:
|
||||
# 构造模拟返回
|
||||
mock_seg1 = MagicMock()
|
||||
mock_seg1.audio_path = Path("/tmp/tts/seg_000.wav")
|
||||
mock_seg1.start_time = 0.0
|
||||
mock_seg1.duration = 2.5
|
||||
mock_seg2 = MagicMock()
|
||||
mock_seg2.audio_path = Path("/tmp/tts/seg_001.wav")
|
||||
mock_seg2.start_time = 2.5
|
||||
mock_seg2.duration = 2.5
|
||||
mock_result = MagicMock()
|
||||
mock_result.success = True
|
||||
mock_result.segments = [mock_seg1, mock_seg2]
|
||||
mock_result.total_duration = 5.0
|
||||
mock_sub_vo.return_value = mock_result
|
||||
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is True, "预设配音+自动字幕应触发 TTS 配音"
|
||||
# 应调用字幕对齐模式
|
||||
mock_sub_vo.assert_called_once()
|
||||
# 应有 audio 图层
|
||||
audio_layer = next((layer for layer in layers if layer.role == "audio"), None)
|
||||
assert audio_layer is not None
|
||||
assert len(audio_layer.clips) == 2 # 2 个字幕对应 2 段配音
|
||||
|
||||
def test_preset_voice_without_auto_subtitle_no_trigger(self):
|
||||
"""只有 voice_id 没有自动字幕 → 不触发配音。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_002",
|
||||
config={
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"subtitle": {"enabled": True, "auto_generated": False},
|
||||
},
|
||||
)
|
||||
mock_asr = MagicMock()
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
asr_service=mock_asr,
|
||||
)
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is False
|
||||
assert not any(layer.role == "audio" for layer in layers)
|
||||
|
||||
def test_preset_voice_no_asr_service_no_trigger(self):
|
||||
"""有 voice_id + 自动字幕但没有 ASR 服务 → 不触发。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_003",
|
||||
config={
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
},
|
||||
)
|
||||
# 不传 asr_service
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
)
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_preset_voice_asr_empty_segments_skip(self):
|
||||
"""ASR 无识别结果 → 跳过配音,不报错。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_004",
|
||||
config={
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
},
|
||||
)
|
||||
mock_asr = MagicMock()
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
asr_service=mock_asr,
|
||||
)
|
||||
|
||||
# ASR 返回空结果
|
||||
svc._asr_timeline_cache = self._make_mock_timeline([])
|
||||
svc._asr_timeline_cached = True
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
# 不抛异常,返回 False 即可
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_asr_cache_reuse_between_subtitle_and_voiceover(self):
|
||||
"""ASR 结果缓存:字幕和配音共用一次 ASR 调用。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_005",
|
||||
config={
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
},
|
||||
)
|
||||
mock_asr = MagicMock()
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
asr_service=mock_asr,
|
||||
)
|
||||
|
||||
# 先模拟调用过一次 ASR(比如字幕模块先调用)
|
||||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||||
[
|
||||
{"text": "测试字幕", "start": 0.0, "end": 2.0},
|
||||
]
|
||||
)
|
||||
svc._asr_timeline_cached = True
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
with patch(
|
||||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||||
) as mock_sub_vo:
|
||||
mock_seg = MagicMock()
|
||||
mock_seg.audio_path = Path("/tmp/tts/seg_000.wav")
|
||||
mock_seg.start_time = 0.0
|
||||
mock_seg.duration = 2.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.success = True
|
||||
mock_result.segments = [mock_seg]
|
||||
mock_result.total_duration = 2.0
|
||||
mock_sub_vo.return_value = mock_result
|
||||
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is True
|
||||
# ASR 服务不应被再次调用(使用缓存)
|
||||
mock_asr.transcribe.assert_not_called()
|
||||
|
||||
def test_tts_config_align_mode_subtitle_also_works(self):
|
||||
"""标准 tts 配置 + align_mode=subtitle 也走字幕对齐模式。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
plan = FakePlan(
|
||||
id="plan_sub_006",
|
||||
config={
|
||||
"tts": {
|
||||
"enabled": True,
|
||||
"voice_id": "longxiaoxia_v3",
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
},
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
},
|
||||
)
|
||||
mock_asr = MagicMock()
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=Path("/tmp/test_tts_sub"),
|
||||
asr_service=mock_asr,
|
||||
)
|
||||
|
||||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||||
[
|
||||
{"text": "字幕1", "start": 0.0, "end": 3.0},
|
||||
{"text": "字幕2", "start": 3.0, "end": 6.0},
|
||||
]
|
||||
)
|
||||
svc._asr_timeline_cached = True
|
||||
|
||||
with _patch_path_exists():
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
with patch(
|
||||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||||
) as mock_sub_vo:
|
||||
mock_seg = MagicMock()
|
||||
mock_seg.audio_path = Path("/tmp/tts/seg_000.wav")
|
||||
mock_seg.start_time = 0.0
|
||||
mock_seg.duration = 6.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.success = True
|
||||
mock_result.segments = [mock_seg, mock_seg]
|
||||
mock_result.total_duration = 6.0
|
||||
mock_sub_vo.return_value = mock_result
|
||||
|
||||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||||
|
||||
assert result is True
|
||||
mock_sub_vo.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user