feat: improve rpa video audio composition
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 50s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 10:14:31 +08:00
parent 36a411810d
commit 0e2b73e1ad
4 changed files with 738 additions and 71 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "1.0.11" version = "1.0.12"
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -23,7 +23,7 @@ __all__ = [
"LaunchError", "LaunchError",
"GatewayDownError", "GatewayDownError",
] ]
__version__ = "1.0.11" __version__ = "1.0.12"
def __getattr__(name: str): def __getattr__(name: str):

View File

@@ -1,9 +1,14 @@
"""RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。""" """RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 旁白 + 背景音乐 → 最终 MP4。
背景音乐默认循环铺满录屏时长并在结尾淡出;旁白使用 Windows 本地 SAPI 合成,
失败时静默降级不影响视频生成。Skill 无需额外配置。
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os import os
import re
import subprocess import subprocess
import sys import sys
import time import time
@@ -18,6 +23,7 @@ from ..media_assets import (
ensure_media_assets, ensure_media_assets,
pick_background_music, pick_background_music,
resolve_ffmpeg, resolve_ffmpeg,
resolve_ffprobe,
) )
_JIANGCHANG_SUBTITLE_STYLE = ( _JIANGCHANG_SUBTITLE_STYLE = (
@@ -28,6 +34,31 @@ _JIANGCHANG_SUBTITLE_STYLE = (
_CAPTURE_FRAMERATE = 15 _CAPTURE_FRAMERATE = 15
# 背景音乐循环 + 结尾淡出(默认策略,非 env 配置)
_MUSIC_VOLUME = 0.12
_VOICE_VOLUME = 1.0
_MUSIC_FADE_OUT_SECONDS = 3.0
_MIN_MUSIC_FADE_OUT_SECONDS = 1.0
_SHORT_VIDEO_FADE_RATIO = 0.25
_VOICEOVER_MAX_CHARS = 60
_VOICEOVER_DEDUP_SECONDS = 10.0
_VOICEOVER_MIN_GAP_SECONDS = 2.0
_VOICEOVER_SKIP_SUBSTRINGS = (
"等待 1-5s",
"跳过重复",
"写入联系人库",
"探针模式",
"异常",
"失败",
)
# 明显技术日志:时间戳前缀、纯数字进度等
_TECH_LOG_RE = re.compile(
r"^\d{4}-\d{2}-\d{2}|^\[\w+\]|^DEBUG:|^INFO:|^WARN:|^ERROR:"
)
def _record_video_enabled() -> bool: def _record_video_enabled() -> bool:
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False) return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
@@ -41,6 +72,68 @@ def _resolve_ffmpeg_exe() -> Optional[Path]:
return path if path.is_file() else None return path if path.is_file() else None
def _resolve_ffprobe_exe() -> Optional[Path]:
"""定位 ffprobe优先 media_assets bundle否则尝试 ffmpeg 同目录。"""
ffprobe = resolve_ffprobe()
if ffprobe is not None:
path = Path(ffprobe)
if path.is_file():
return path
ffmpeg = _resolve_ffmpeg_exe()
if ffmpeg is not None:
sibling = ffmpeg.parent / ("ffprobe.exe" if sys.platform == "win32" else "ffprobe")
if sibling.is_file():
return sibling
return None
def _probe_media_duration_seconds(
ffprobe_exe: Optional[Path], media_path: Path
) -> Optional[float]:
if ffprobe_exe is None or not media_path.is_file():
return None
try:
result = subprocess.run(
[
str(ffprobe_exe),
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nk=1:nw=1",
str(media_path),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
if result.returncode != 0:
return None
raw = (result.stdout or "").strip()
if not raw:
return None
value = float(raw)
return value if value > 0 else None
except Exception:
return None
def _music_fade_params(
duration_seconds: Optional[float],
) -> tuple[Optional[float], Optional[float]]:
if duration_seconds is None or duration_seconds <= 0:
return (None, None)
fade = min(
_MUSIC_FADE_OUT_SECONDS,
max(_MIN_MUSIC_FADE_OUT_SECONDS, duration_seconds * _SHORT_VIDEO_FADE_RATIO),
)
fade_start = max(0.0, duration_seconds - fade)
return (fade_start, fade)
def _resolve_music_file(warnings: List[str]) -> Optional[Path]: def _resolve_music_file(warnings: List[str]) -> Optional[Path]:
music = pick_background_music() music = pick_background_music()
if music is not None: if music is not None:
@@ -59,6 +152,173 @@ class _StepEntry:
duration: float = 4.0 duration: float = 4.0
@dataclass
class _VoiceClip:
path: Path
delay_ms: int
def _voiceover_text_for_step(text: str) -> Optional[str]:
"""过滤适合朗读的步骤文本;噪音/技术日志返回 None。"""
cleaned = (text or "").strip()
if not cleaned:
return None
if _TECH_LOG_RE.search(cleaned):
return None
for skip in _VOICEOVER_SKIP_SUBSTRINGS:
if skip in cleaned:
return None
if len(cleaned) > _VOICEOVER_MAX_CHARS:
cleaned = cleaned[:_VOICEOVER_MAX_CHARS]
return cleaned
def _select_voiceover_clips(
entries: List[_StepEntry],
) -> List[tuple[_StepEntry, str]]:
"""按时间排序,去重并保证旁白间隔。"""
result: List[tuple[_StepEntry, str]] = []
last_text_at: Dict[str, float] = {}
last_spoken_at = -_VOICEOVER_MIN_GAP_SECONDS
for entry in sorted(entries, key=lambda e: e.start):
spoken = _voiceover_text_for_step(entry.text)
if not spoken:
continue
prev = last_text_at.get(spoken)
if prev is not None and entry.start - prev < _VOICEOVER_DEDUP_SECONDS:
continue
if entry.start - last_spoken_at < _VOICEOVER_MIN_GAP_SECONDS:
continue
last_text_at[spoken] = entry.start
last_spoken_at = entry.start
result.append((entry, spoken))
return result
def _tts_available() -> bool:
return sys.platform == "win32"
def _sapi_synthesize_wav(text: str, output_wav: Path) -> bool:
if not _tts_available():
return False
output_wav.parent.mkdir(parents=True, exist_ok=True)
out_str = str(output_wav.resolve()).replace("'", "''")
text_esc = text.replace("'", "''")
ps_script = (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$s.SetOutputToWaveFile('{out_str}'); "
f"$s.Speak('{text_esc}'); "
"$s.Dispose()"
)
try:
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
return (
result.returncode == 0
and output_wav.is_file()
and output_wav.stat().st_size > 0
)
except Exception:
return False
def _mix_voice_clips(
ffmpeg_exe: Path, clips: List[_VoiceClip], output_wav: Path
) -> bool:
if not clips:
return False
output_wav.parent.mkdir(parents=True, exist_ok=True)
filter_parts: List[str] = []
mix_labels: List[str] = []
cmd_inputs: List[str] = []
for i, clip in enumerate(clips):
cmd_inputs.extend(["-i", str(clip.path)])
label = f"v{i}"
filter_parts.append(
f"[{i}:a]adelay={clip.delay_ms}|{clip.delay_ms},"
f"volume={_VOICE_VOLUME}[{label}]"
)
mix_labels.append(f"[{label}]")
if len(clips) == 1:
filter_complex = filter_parts[0].replace(f"[v0]", "[out]")
# single clip: relabel output
filter_complex = (
f"[0:a]adelay={clips[0].delay_ms}|{clips[0].delay_ms},"
f"volume={_VOICE_VOLUME}[out]"
)
else:
filter_complex = (
";".join(filter_parts)
+ ";"
+ "".join(mix_labels)
+ f"amix=inputs={len(clips)}:duration=longest:dropout_transition=0:normalize=0[out]"
)
cmd_tail = [
"-y",
*cmd_inputs,
"-filter_complex",
filter_complex,
"-map",
"[out]",
str(output_wav),
]
ok, _ = _run_ffmpeg(ffmpeg_exe, cmd_tail)
return ok and output_wav.is_file()
def _synthesize_step_voiceover(
entries: List[_StepEntry],
output_wav: Path,
warnings: List[str],
*,
ffmpeg_exe: Optional[Path] = None,
) -> Optional[Path]:
"""本地 Windows SAPI 逐条合成旁白并按步骤 start 时间对齐;失败返回 None。"""
if not _tts_available():
warnings.append("tts_unsupported_platform")
return None
clips_to_speak = _select_voiceover_clips(entries)
if not clips_to_speak:
return None
voice_dir = output_wav.parent
voice_dir.mkdir(parents=True, exist_ok=True)
temp_clips: List[_VoiceClip] = []
for i, (entry, spoken) in enumerate(clips_to_speak):
clip_path = voice_dir / f"voice_{i:03d}.wav"
if not _sapi_synthesize_wav(spoken, clip_path):
warnings.append("tts_synthesis_failed")
return None
delay_ms = max(0, int(entry.start * 1000))
temp_clips.append(_VoiceClip(path=clip_path, delay_ms=delay_ms))
if ffmpeg_exe is None:
ffmpeg_exe = _resolve_ffmpeg_exe()
if ffmpeg_exe is None:
warnings.append("tts_mix_ffmpeg_missing")
return None
if not _mix_voice_clips(ffmpeg_exe, temp_clips, output_wav):
warnings.append("tts_mix_failed")
return None
return output_wav
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None: def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
MIN_DURATION = 1.0 MIN_DURATION = 1.0
GAP = 0.1 GAP = 0.1
@@ -117,27 +377,16 @@ def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: List[str]) -> tuple[bool, str]:
return True, "" return True, ""
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]: def _build_music_filter(
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。""" audio_input_idx: int,
if sys.platform != "win32": fade_start: Optional[float],
return None fade_duration: Optional[float],
return _ffmpeg_cmd( output_label: str = "music",
ffmpeg_exe, ) -> str:
"-y", filt = f"[{audio_input_idx}:a]volume={_MUSIC_VOLUME}"
"-f", if fade_start is not None and fade_duration is not None:
"gdigrab", filt += f",afade=t=out:st={fade_start}:d={fade_duration}"
"-framerate", return filt + f"[{output_label}]"
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
)
def _compose_capture_mp4( def _compose_capture_mp4(
@@ -147,7 +396,8 @@ def _compose_capture_mp4(
output_path: Path, output_path: Path,
*, *,
music_file: Optional[Path] = None, music_file: Optional[Path] = None,
music_volume: float = 0.15, voiceover_file: Optional[Path] = None,
warnings: Optional[List[str]] = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
srt_esc = _escape_srt_path(str(srt_path.resolve())) srt_esc = _escape_srt_path(str(srt_path.resolve()))
@@ -156,38 +406,77 @@ def _compose_capture_mp4(
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'" f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
) )
if music_file and music_file.is_file(): has_music = music_file is not None and music_file.is_file()
filter_complex = ( has_voice = voiceover_file is not None and voiceover_file.is_file()
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]" fade_start: Optional[float] = None
fade_duration: Optional[float] = None
if has_music:
ffprobe = _resolve_ffprobe_exe()
duration = _probe_media_duration_seconds(ffprobe, capture_path)
fade_start, fade_duration = _music_fade_params(duration)
if fade_start is None and warnings is not None:
warnings.append("video_duration_probe_failed_for_music_fade")
if has_music or has_voice:
cmd_tail: List[str] = ["-y", "-i", str(capture_path)]
input_idx = 1
filter_parts: List[str] = [f"[0:v]{subtitle_filter}[vout]"]
audio_labels: List[str] = []
if has_music:
cmd_tail.extend(["-stream_loop", "-1", "-i", str(music_file)])
music_label = "aout" if not has_voice else "music"
filter_parts.append(
_build_music_filter(input_idx, fade_start, fade_duration, music_label)
)
if has_voice:
audio_labels.append(f"[{music_label}]")
input_idx += 1
if has_voice:
cmd_tail.extend(["-i", str(voiceover_file)])
if has_music:
filter_parts.append(
f"[{input_idx}:a]volume={_VOICE_VOLUME}[voice]"
)
audio_labels.append("[voice]")
else:
# voice-onlyapad 补静音,避免 -shortest 按旁白长度截短视频
filter_parts.append(
f"[{input_idx}:a]volume={_VOICE_VOLUME},apad[aout]"
)
input_idx += 1
if len(audio_labels) == 2:
filter_parts.append(
"".join(audio_labels) + "amix=inputs=2:normalize=0[aout]"
)
cmd_tail.extend(
[
"-filter_complex",
";".join(filter_parts),
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-pix_fmt",
"yuv420p",
str(output_path),
]
) )
cmd_tail = [
"-y",
"-i",
str(capture_path),
"-i",
str(music_file),
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-pix_fmt",
"yuv420p",
str(output_path),
]
else: else:
cmd_tail = [ cmd_tail = [
"-y", "-y",
@@ -212,6 +501,29 @@ def _compose_capture_mp4(
return _run_ffmpeg(ffmpeg_exe, cmd_tail) return _run_ffmpeg(ffmpeg_exe, cmd_tail)
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
if sys.platform != "win32":
return None
return _ffmpeg_cmd(
ffmpeg_exe,
"-y",
"-f",
"gdigrab",
"-framerate",
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
)
class RpaVideoSession: class RpaVideoSession:
"""Service/RPA 层统一 ffmpeg 桌面录屏OPENCLAW_RECORD_VIDEO=1 时启用成片流程。""" """Service/RPA 层统一 ffmpeg 桌面录屏OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
@@ -242,12 +554,17 @@ class RpaVideoSession:
self.skill_data_dir, "rpa-artifacts", batch_id self.skill_data_dir, "rpa-artifacts", batch_id
) )
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles") self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
self._voiceover_dir = os.path.join(self._artifact_root, "voiceover")
self._logs_dir = os.path.join(self._artifact_root, "logs") self._logs_dir = os.path.join(self._artifact_root, "logs")
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log") self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
self._capture_path = os.path.join(self._artifact_root, "capture.mp4") self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt") self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
self._voiceover_path = os.path.join(self._voiceover_dir, "voiceover.wav")
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4") self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
self._music_path: Optional[str] = None
self._voiceover_output: Optional[str] = None
self._steps: List[_StepEntry] = [] self._steps: List[_StepEntry] = []
self._t0: Optional[float] = None self._t0: Optional[float] = None
self._ffmpeg_proc: Optional[subprocess.Popen] = None self._ffmpeg_proc: Optional[subprocess.Popen] = None
@@ -256,6 +573,7 @@ class RpaVideoSession:
if self.enabled: if self.enabled:
os.makedirs(self._videos_dir, exist_ok=True) os.makedirs(self._videos_dir, exist_ok=True)
os.makedirs(self._subtitles_dir, exist_ok=True) os.makedirs(self._subtitles_dir, exist_ok=True)
os.makedirs(self._voiceover_dir, exist_ok=True)
os.makedirs(self._logs_dir, exist_ok=True) os.makedirs(self._logs_dir, exist_ok=True)
os.makedirs(self._artifact_root, exist_ok=True) os.makedirs(self._artifact_root, exist_ok=True)
@@ -379,6 +697,54 @@ class RpaVideoSession:
self._stop_ffmpeg_capture() self._stop_ffmpeg_capture()
await self.finalize() await self.finalize()
def _try_compose_variants(
self,
ffmpeg_exe: Path,
capture: Path,
srt_file: Path,
output: Path,
*,
music: Optional[Path],
voiceover: Optional[Path],
use_srt: bool,
) -> tuple[bool, str]:
"""按 music/voice 组合尝试合成,失败时逐级降级。"""
srt = srt_file if use_srt else Path(self._srt_path)
variants: List[tuple[Optional[Path], Optional[Path]]] = [
(music, voiceover),
]
if music and voiceover:
variants.append((None, voiceover))
if music:
variants.append((music, None))
variants.append((None, None))
seen: set[tuple[Optional[str], Optional[str]]] = set()
last_err = ""
for m, v in variants:
key = (str(m) if m else None, str(v) if v else None)
if key in seen:
continue
seen.add(key)
ok, err = _compose_capture_mp4(
ffmpeg_exe,
capture,
srt,
output,
music_file=m,
voiceover_file=v,
warnings=self.warnings,
)
if ok:
return True, ""
last_err = err
if m is not None and v is not None:
self.warnings.append(f"ffmpeg_with_music_and_voice_failed: {err}")
elif m is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
return False, last_err
async def finalize(self) -> None: async def finalize(self) -> None:
"""停止录制后合成最终 MP4失败只记 warning不抛异常。""" """停止录制后合成最终 MP4失败只记 warning不抛异常。"""
if not self.enabled: if not self.enabled:
@@ -406,7 +772,12 @@ class RpaVideoSession:
self.warnings.append(f"subtitle_write_failed: {exc}") self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file(self.warnings) music = _resolve_music_file(self.warnings)
if music is None and "background_music_lfs_pointer_only" not in self.warnings and "background_music_invalid" not in self.warnings: if music is not None:
self._music_path = str(music.resolve())
elif (
"background_music_lfs_pointer_only" not in self.warnings
and "background_music_invalid" not in self.warnings
):
self.warnings.append("background_music_missing") self.warnings.append("background_music_missing")
ffmpeg_exe = _resolve_ffmpeg_exe() ffmpeg_exe = _resolve_ffmpeg_exe()
@@ -415,25 +786,32 @@ class RpaVideoSession:
self.artifacts = self.summary() self.artifacts = self.summary()
return return
voiceover: Optional[Path] = None
try:
vo = _synthesize_step_voiceover(
entries,
Path(self._voiceover_path),
self.warnings,
ffmpeg_exe=ffmpeg_exe,
)
if vo is not None:
voiceover = vo
self._voiceover_output = str(vo.resolve())
except Exception as exc:
self.warnings.append(f"tts_unexpected_error: {exc}")
srt_file = Path(self._srt_path) srt_file = Path(self._srt_path)
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0 use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
ok, err = _compose_capture_mp4( ok, err = self._try_compose_variants(
ffmpeg_exe, ffmpeg_exe,
capture, capture,
srt_file if use_srt else Path(self._srt_path), srt_file,
Path(self._planned_output), Path(self._planned_output),
music_file=music, music=music,
voiceover=voiceover,
use_srt=use_srt,
) )
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_capture_mp4(
ffmpeg_exe,
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
music_file=None,
)
if not ok and not use_srt: if not ok and not use_srt:
ok, err = _run_ffmpeg( ok, err = _run_ffmpeg(
ffmpeg_exe, ffmpeg_exe,
@@ -469,12 +847,28 @@ class RpaVideoSession:
if self.enabled and self._ffmpeg_record_log_path if self.enabled and self._ffmpeg_record_log_path
else None else None
) )
audio_warnings = [
w
for w in self.warnings
if w.startswith(
(
"tts_",
"video_duration_probe_failed",
"ffmpeg_with_music",
"background_music_",
)
)
or w == "background_music_missing"
]
return { return {
"enabled": self.enabled, "enabled": self.enabled,
"path": self.output_video_path, "path": self.output_video_path,
"capture_path": self.capture_path, "capture_path": self.capture_path,
"record_log_path": record_log, "record_log_path": record_log,
"warnings": list(self.warnings), "warnings": list(self.warnings),
"voiceover_path": self._voiceover_output,
"music_path": self._music_path,
"audio_warnings": audio_warnings,
} }
def to_artifact_dict(self) -> Dict[str, Any]: def to_artifact_dict(self) -> Dict[str, Any]:

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""RpaVideoSessionffmpeg 桌面录屏。""" """RpaVideoSessionffmpeg 桌面录屏、背景音乐循环淡出、旁白混音"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -9,8 +9,17 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from jiangchang_skill_core import config from jiangchang_skill_core import config
from jiangchang_skill_core.rpa.video_session import RpaVideoSession from jiangchang_skill_core.rpa import video_session as vs
from jiangchang_skill_core.rpa.video_session import (
RpaVideoSession,
_StepEntry,
_compose_capture_mp4,
_music_fade_params,
_select_voiceover_clips,
_voiceover_text_for_step,
)
class TestRpaVideoSession(unittest.TestCase): class TestRpaVideoSession(unittest.TestCase):
@@ -33,6 +42,8 @@ class TestRpaVideoSession(unittest.TestCase):
self.assertIsNone(summ["path"]) self.assertIsNone(summ["path"])
self.assertIsNone(summ["capture_path"]) self.assertIsNone(summ["capture_path"])
self.assertIsNone(summ["record_log_path"]) self.assertIsNone(summ["record_log_path"])
self.assertIsNone(summ["voiceover_path"])
self.assertIsNone(summ["music_path"])
finally: finally:
if old is not None: if old is not None:
os.environ["OPENCLAW_RECORD_VIDEO"] = old os.environ["OPENCLAW_RECORD_VIDEO"] = old
@@ -60,6 +71,11 @@ class TestRpaVideoSession(unittest.TestCase):
"/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log" "/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log"
) )
) )
self.assertTrue(
session._voiceover_path.replace("\\", "/").endswith(
"/rpa-artifacts/exmail_20260602_120000/voiceover/voiceover.wav"
)
)
self.assertTrue( self.assertTrue(
session._planned_output.replace("\\", "/").startswith( session._planned_output.replace("\\", "/").startswith(
tmp.replace("\\", "/") + "/videos/" tmp.replace("\\", "/") + "/videos/"
@@ -144,5 +160,262 @@ class TestRpaVideoSession(unittest.TestCase):
config.reset_cache() config.reset_cache()
class TestMusicFadeParams(unittest.TestCase):
def test_normal_duration(self) -> None:
start, dur = _music_fade_params(20.0)
self.assertAlmostEqual(start, 17.0)
self.assertAlmostEqual(dur, 3.0)
def test_short_video(self) -> None:
start, dur = _music_fade_params(4.0)
self.assertAlmostEqual(dur, 1.0)
self.assertAlmostEqual(start, 3.0)
def test_invalid_duration(self) -> None:
self.assertEqual(_music_fade_params(None), (None, None))
self.assertEqual(_music_fade_params(0), (None, None))
self.assertEqual(_music_fade_params(-1), (None, None))
class TestVoiceoverTextFilter(unittest.TestCase):
def test_keeps_meaningful_steps(self) -> None:
for text in (
"开始采集",
"获取1688账号",
"启动浏览器",
"检查登录状态",
"搜索关键词:螺丝",
"解析第 1 页店铺列表",
"打开店铺联系方式页",
"提取联系人信息",
"采集完成",
):
self.assertIsNotNone(_voiceover_text_for_step(text), msg=text)
def test_skips_noise(self) -> None:
for text in (
"等待 1-5s",
"跳过重复店铺",
"写入联系人库",
"探针模式",
"操作异常",
"登录失败",
"2024-01-01 12:00:00 INFO: debug",
"",
" ",
):
self.assertIsNone(_voiceover_text_for_step(text), msg=text)
def test_truncates_long_text(self) -> None:
long_text = "" * 80
result = _voiceover_text_for_step(long_text)
self.assertIsNotNone(result)
assert result is not None
self.assertEqual(len(result), 60)
def test_dedup_within_ten_seconds(self) -> None:
entries = [
_StepEntry(0.0, "启动浏览器"),
_StepEntry(5.0, "启动浏览器"),
_StepEntry(12.0, "启动浏览器"),
]
clips = _select_voiceover_clips(entries)
texts = [t for _, t in clips]
self.assertEqual(texts.count("启动浏览器"), 2)
def test_min_gap_two_seconds(self) -> None:
entries = [
_StepEntry(0.0, "开始采集"),
_StepEntry(1.0, "启动浏览器"),
_StepEntry(3.0, "检查登录状态"),
]
clips = _select_voiceover_clips(entries)
self.assertEqual(len(clips), 2)
self.assertEqual(clips[0][1], "开始采集")
self.assertEqual(clips[1][1], "检查登录状态")
class TestComposeCaptureMp4(unittest.TestCase):
def setUp(self) -> None:
self.ffmpeg = Path(r"C:\ffmpeg\ffmpeg.exe")
self.capture = Path(r"C:\tmp\capture.mp4")
self.srt = Path(r"C:\tmp\sub.srt")
self.output = Path(r"C:\tmp\out.mp4")
self.music = Path(r"C:\tmp\music.mp3")
self.voice = Path(r"C:\tmp\voiceover.wav")
def _run_compose(self, **kwargs) -> list[str]:
captured: list[str] = []
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
captured.extend(cmd_tail)
return True, ""
with (
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
patch.object(vs, "_resolve_ffprobe_exe", return_value=Path(r"C:\ffmpeg\ffprobe.exe")),
patch.object(vs, "_probe_media_duration_seconds", return_value=kwargs.pop("duration", 20.0)),
patch.object(Path, "is_file", return_value=True),
):
_compose_capture_mp4(
self.ffmpeg,
self.capture,
self.srt,
self.output,
music_file=kwargs.get("music_file"),
voiceover_file=kwargs.get("voiceover_file"),
warnings=kwargs.get("warnings"),
)
return captured
def test_music_loop_params(self) -> None:
cmd = self._run_compose(music_file=self.music)
cmd_str = " ".join(cmd)
loop_idx = cmd.index("-stream_loop")
minus_one_idx = cmd.index("-1", loop_idx)
music_i_idx = cmd.index("-i", minus_one_idx)
self.assertEqual(cmd[music_i_idx + 1], str(self.music))
self.assertIn("-shortest", cmd)
filter_idx = cmd.index("-filter_complex")
filt = cmd[filter_idx + 1]
self.assertNotIn("apad", filt)
def test_music_fade_filter_long_video(self) -> None:
cmd = self._run_compose(music_file=self.music, duration=20.0)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("afade=t=out:st=17", filt)
self.assertIn("d=3", filt)
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
def test_music_fade_filter_short_video(self) -> None:
cmd = self._run_compose(music_file=self.music, duration=4.0)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("afade=t=out:st=3", filt)
self.assertIn("d=1", filt)
def test_duration_probe_failed_no_afade(self) -> None:
warnings: list[str] = []
captured: list[str] = []
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
captured.extend(cmd_tail)
return True, ""
with (
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
patch.object(vs, "_resolve_ffprobe_exe", return_value=None),
patch.object(vs, "_probe_media_duration_seconds", return_value=None),
patch.object(Path, "is_file", return_value=True),
):
ok, _ = _compose_capture_mp4(
self.ffmpeg,
self.capture,
self.srt,
self.output,
music_file=self.music,
warnings=warnings,
)
self.assertTrue(ok)
self.assertIn("video_duration_probe_failed_for_music_fade", warnings)
filt = captured[captured.index("-filter_complex") + 1]
self.assertNotIn("afade", filt)
self.assertIn("-stream_loop", captured)
def test_music_and_voice_amix(self) -> None:
cmd = self._run_compose(music_file=self.music, voiceover_file=self.voice)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("amix", filt)
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
self.assertLess(vs._MUSIC_VOLUME, vs._VOICE_VOLUME)
self.assertIn("afade", filt)
self.assertNotIn("apad", filt)
self.assertIn("-stream_loop", cmd)
self.assertIn("-shortest", cmd)
def test_voice_only_no_amix(self) -> None:
cmd = self._run_compose(voiceover_file=self.voice)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertNotIn("amix", filt)
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
self.assertIn("apad", filt)
self.assertIn("-shortest", cmd)
self.assertNotIn("-stream_loop", cmd)
class TestFinalizeDegradation(unittest.TestCase):
def setUp(self) -> None:
config.reset_cache()
def tearDown(self) -> None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
config.reset_cache()
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
def test_tts_failure_still_composes(
self,
mock_compose: MagicMock,
mock_tts: MagicMock,
mock_music: MagicMock,
mock_ffmpeg: MagicMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
mock_music.return_value = Path(r"C:\tmp\music.mp3")
mock_tts.return_value = None
mock_compose.return_value = (True, "")
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
capture.parent.mkdir(parents=True)
capture.write_bytes(b"fake")
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch",
)
session.add_step("启动浏览器")
asyncio.run(session.finalize())
self.assertTrue(mock_compose.called)
self.assertIsNotNone(session.output_video_path)
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
def test_compose_fallback_without_music(
self,
mock_compose: MagicMock,
mock_tts: MagicMock,
mock_music: MagicMock,
mock_ffmpeg: MagicMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
mock_music.return_value = Path(r"C:\tmp\music.mp3")
mock_tts.return_value = None
mock_compose.side_effect = [(False, "music err"), (True, "")]
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
capture.parent.mkdir(parents=True)
capture.write_bytes(b"fake")
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch",
)
asyncio.run(session.finalize())
self.assertEqual(mock_compose.call_count, 2)
self.assertTrue(any("ffmpeg_with_music_failed" in w for w in session.warnings))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()