基于 SDK 新模块(前两个 commit)的 screencast 工具能力升级:
- runner.py:
· 新增 activate_window: bool = True 参数,录屏前自动调
activate_and_maximize_jiangchang_window,避免开头几秒录到 PowerShell/桌面
· 新增 monitor_index: Optional[int] = None 参数,透传给 ScreenRecorder
· 自带 SDK 路径兜底 _ensure_sdk_on_path,让 record_screencast.py 直接 python
跑时(不走 pytest conftest)也能 import jiangchang_desktop_sdk.window_win32
- recorder.py:
· ScreenRecorder.__init__ 加 monitor_index 参数
· _capture_loop 用 idx 选 monitor,越界兜底到 0
· None = 旧行为(monitors[0] 所有屏合并),1 = 主屏,2+ = 第 N 屏
· 多显示器场景推荐显式传 monitor_index=1 避免录出半边空白
- subtitle.py:
· SubtitleEngine.process_line 支持二元组(兼容旧脚本)和三元组(每条独立 duration)
· 长等待场景可以让某条字幕停留更久(如 30s)覆盖中间无日志的"字幕真空"
所有改动向后兼容,全部新增参数有默认值。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
"""主编排器:录屏 + 运行 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
|
||
|
||
|
||
def _ensure_sdk_on_path() -> None:
|
||
"""record_screencast.py 直接 python 跑时不走 conftest,SDK 可能不在 sys.path。
|
||
自带兜底:从 tools/screencast/runner.py 上溯到 platform-kit/src。"""
|
||
import importlib.util
|
||
if importlib.util.find_spec("jiangchang_desktop_sdk") is not None:
|
||
return
|
||
# tools/screencast/runner.py → tools/screencast → tools → platform-kit
|
||
here = Path(__file__).resolve()
|
||
src = here.parent.parent.parent / "src"
|
||
if (src / "jiangchang_desktop_sdk").is_dir():
|
||
import sys as _sys
|
||
_sys.path.insert(0, str(src))
|
||
|
||
|
||
_ensure_sdk_on_path()
|
||
from jiangchang_desktop_sdk.window_win32 import activate_and_maximize_jiangchang_window # noqa: E402
|
||
|
||
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,
|
||
activate_window: bool = True,
|
||
monitor_index: Optional[int] = None,
|
||
) -> 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
|
||
activate_window: 录屏开始前是否自动激活+最大化匠厂窗口(默认 True;
|
||
Windows 走 ctypes,非 Windows 走 jiangchang:// 协议)
|
||
monitor_index: 指定录哪个显示器;None=所有显示器合并(旧行为),
|
||
1=主屏,2+=第 N 屏(推荐多显示器场景显式传 1)
|
||
|
||
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, monitor_index=monitor_index)
|
||
|
||
if activate_window:
|
||
ok = activate_and_maximize_jiangchang_window()
|
||
if ok:
|
||
print("[screencast] 已激活+最大化匠厂窗口")
|
||
time.sleep(2) # 等窗口浮起稳定
|
||
else:
|
||
print("[screencast] 未能激活匠厂窗口,继续录制(可能录到桌面边角)")
|
||
|
||
print(f"[screencast] 启动录制 → {output_mp4.name}")
|
||
recorder.start()
|
||
engine.set_start_time(time.time())
|
||
|
||
try:
|
||
# Windows 子进程 stdout 默认走系统 ANSI code page(中文 Windows 是 cp936/GBK),
|
||
# 这边用 encoding="utf-8" 解码会乱码。注入 PYTHONIOENCODING + PYTHONUTF8 让
|
||
# 子进程 Python 强制以 UTF-8 输出,两端编码对齐。非 Windows 默认就 UTF-8,无副作用。
|
||
env = os.environ.copy()
|
||
env["PYTHONIOENCODING"] = "utf-8"
|
||
env["PYTHONUTF8"] = "1"
|
||
|
||
proc = subprocess.Popen(
|
||
[sys.executable, "-m", "pytest", *pytest_args],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
env=env,
|
||
)
|
||
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)
|