feat(tools): add screencast recording engine
Adds tools/screencast/ — a reusable screen-recording pipeline for skill demo videos. Wraps a pytest desktop E2E run with: - ScreenRecorder: mss-based full-screen frame capture (background thread) - SubtitleEngine: stdout keyword → timestamped SRT generation - compose_video: FFmpeg filter_complex merge (video + subtitles + BGM) - run_screencast: main orchestrator wiring all three layers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
100
tools/screencast/runner.py
Normal file
100
tools/screencast/runner.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""主编排器:录屏 + 运行 pytest + 生成字幕 + FFmpeg 合成。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .composer import compose_video
|
||||
from .recorder import ScreenRecorder
|
||||
from .subtitle import SubtitleEngine
|
||||
|
||||
|
||||
def run_screencast(
|
||||
skill_slug: str,
|
||||
subtitle_script: List[Tuple[str, str]],
|
||||
pytest_args: List[str],
|
||||
output_dir: str,
|
||||
media_assets_root: Optional[str] = None,
|
||||
music_subdir: str = "music",
|
||||
fps: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
录制一次技能桌面 E2E 演示视频。
|
||||
|
||||
Args:
|
||||
skill_slug: 技能唯一标识,如 "query-balance-icbc"
|
||||
subtitle_script: [(关键词, 字幕文案), ...] 按顺序匹配 pytest stdout
|
||||
pytest_args: 传递给 pytest 的参数列表
|
||||
output_dir: 最终 MP4 输出目录
|
||||
media_assets_root: media-assets 仓库本地路径;
|
||||
不传时读 MEDIA_ASSETS_ROOT 环境变量,
|
||||
再不存在则用默认值 D:\\OpenClaw\\client-commons\\media-assets
|
||||
music_subdir: music_assets_root 下音乐子目录,默认 "music"
|
||||
fps: 截帧帧率,默认 10
|
||||
|
||||
Returns:
|
||||
输出 MP4 的绝对路径
|
||||
"""
|
||||
if media_assets_root is None:
|
||||
media_assets_root = os.environ.get(
|
||||
"MEDIA_ASSETS_ROOT",
|
||||
r"D:\OpenClaw\client-commons\media-assets",
|
||||
)
|
||||
|
||||
music_dir = str(Path(media_assets_root) / music_subdir)
|
||||
output_dir_path = Path(output_dir)
|
||||
output_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base_name = f"{skill_slug}_{date_str}"
|
||||
output_mp4 = output_dir_path / f"{base_name}.mp4"
|
||||
subtitle_file = output_dir_path / f"{base_name}.srt"
|
||||
|
||||
engine = SubtitleEngine(subtitle_script)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="screencast_frames_") as frames_dir:
|
||||
recorder = ScreenRecorder(frames_dir, fps=fps)
|
||||
|
||||
print(f"[screencast] 启动录制 → {output_mp4.name}")
|
||||
recorder.start()
|
||||
engine.set_start_time(time.time())
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "pytest", *pytest_args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
sys.stdout.write(line)
|
||||
sys.stdout.flush()
|
||||
engine.process_line(line)
|
||||
exit_code = proc.wait()
|
||||
if exit_code != 0:
|
||||
print(f"[screencast] pytest 退出码 {exit_code},录制仍继续合成")
|
||||
finally:
|
||||
recorder.stop()
|
||||
print(f"[screencast] 录制停止,共 {recorder.frame_count} 帧")
|
||||
|
||||
engine.generate_srt(str(subtitle_file))
|
||||
print(f"[screencast] 字幕 → {subtitle_file.name}")
|
||||
|
||||
compose_video(
|
||||
frames_dir=frames_dir,
|
||||
fps=fps,
|
||||
subtitle_path=str(subtitle_file),
|
||||
music_dir=music_dir,
|
||||
output_path=str(output_mp4),
|
||||
)
|
||||
|
||||
return str(output_mp4)
|
||||
Reference in New Issue
Block a user