chore: sync ffmpeg RpaVideoSession, remove Playwright recording
All checks were successful
技能自动化发布 / release (push) Successful in 5s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:07:02 +08:00
parent 38343d22ca
commit f52536bdcc
4 changed files with 200 additions and 73 deletions

View File

@@ -159,10 +159,11 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
- **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png` - **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。 - **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **运行录屏成片**RPA 型技能默认 `OPENCLAW_RECORD_VIDEO=1`,表示录屏、中文字幕、背景音乐最终 MP4 **一体开启**。由 service 核心中的 `RpaVideoSession` 统一触发(见 `jiangchang_skill_core.rpa.video_session`,不在 CLI/E2E 入口单独实现。 - **运行录屏成片**RPA 型技能默认 `OPENCLAW_RECORD_VIDEO=1`,表示 **ffmpeg 录制运行过程** + 字幕 + 背景音乐 + 最终 MP4 **一体开启**。由 `RpaVideoSession` 统一触发,不在 CLI/E2E 单独实现。
- **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4` - **Playwright 不负责录屏**,仅浏览器自动化
- **中间产物**`{skill_data_dir}/rpa-artifacts/{batch_id}/`raw-video、字幕、失败截图等**不放最终 MP4** - **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`
- Playwright 使用 `record_video_dir` 指向 `rpa-artifacts/{batch_id}/raw-video/` - **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等,**不放最终 MP4**。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error` - **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`
--- ---

View File

@@ -1,5 +1,4 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit. """统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
"""统一 persistent context 启动封装。"""
from __future__ import annotations from __future__ import annotations
@@ -25,12 +24,11 @@ async def launch_persistent_browser(
executable_path: str | None = None, executable_path: str | None = None,
headless: bool | None = None, headless: bool | None = None,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
record_video_dir: str | None = None,
) -> "BrowserContext": ) -> "BrowserContext":
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。 """用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。 headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
record_video_dir 非空则开录屏 录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数
""" """
if headless is None: if headless is None:
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
@@ -53,8 +51,6 @@ async def launch_persistent_browser(
) )
if ignore is not None: if ignore is not None:
launch_kwargs["ignore_default_args"] = ignore launch_kwargs["ignore_default_args"] = ignore
if record_video_dir:
launch_kwargs["record_video_dir"] = record_video_dir
context = await playwright.chromium.launch_persistent_context(**launch_kwargs) context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
if stealth_enabled(): if stealth_enabled():

View File

@@ -1,10 +1,14 @@
"""RPA 运行录屏会话:Playwright raw video → 字幕 + 背景音乐 → 最终 MP4。""" """RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。
Playwright 仅用于浏览器自动化,不负责录屏。
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os import os
import random import random
import shutil
import subprocess import subprocess
import sys import sys
import time import time
@@ -15,7 +19,6 @@ from typing import Any, Dict, List, Optional
from .. import config from .. import config
# 与 screencast/composer.py 保持一致的字幕样式
_JIANGCHANG_SUBTITLE_STYLE = ( _JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14," "FontName=Arial,FontSize=14,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000," "PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
@@ -27,6 +30,8 @@ _DEFAULT_MUSIC_ROOT = os.environ.get(
r"D:\OpenClaw\client-commons\media-assets", r"D:\OpenClaw\client-commons\media-assets",
) )
_CAPTURE_FRAMERATE = 15
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)
@@ -50,7 +55,6 @@ class _StepEntry:
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None: def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
"""根据步骤时间线生成 SRT防重叠"""
MIN_DURATION = 1.0 MIN_DURATION = 1.0
GAP = 0.1 GAP = 0.1
steps = sorted(entries, key=lambda e: e.start) steps = sorted(entries, key=lambda e: e.start)
@@ -86,16 +90,6 @@ def _escape_srt_path(path: str) -> str:
return path.replace("\\", "/").replace(":", "\\:") return path.replace("\\", "/").replace(":", "\\:")
def _find_raw_webm(raw_dir: str) -> Optional[Path]:
root = Path(raw_dir)
if not root.is_dir():
return None
webms = list(root.rglob("*.webm"))
if not webms:
return None
return max(webms, key=lambda p: p.stat().st_size)
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]: def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
try: try:
result = subprocess.run( result = subprocess.run(
@@ -113,8 +107,31 @@ def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
return True, "" return True, ""
def _compose_webm_mp4( def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
webm_path: Path, """Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
if sys.platform != "win32":
return None
return [
"ffmpeg",
"-y",
"-f",
"gdigrab",
"-framerate",
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
]
def _compose_capture_mp4(
capture_path: Path,
srt_path: Path, srt_path: Path,
output_path: Path, output_path: Path,
*, *,
@@ -134,39 +151,60 @@ def _compose_webm_mp4(
f"[1:a]volume={music_volume},apad[aout]" f"[1:a]volume={music_volume},apad[aout]"
) )
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg",
"-i", str(webm_path), "-y",
"-i", str(music_file), "-i",
"-filter_complex", filter_complex, str(capture_path),
"-map", "[vout]", "-i",
"-map", "[aout]", str(music_file),
"-c:v", "libx264", "-filter_complex",
"-preset", "fast", filter_complex,
"-crf", "23", "-map",
"-c:a", "aac", "[vout]",
"-b:a", "128k", "-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest", "-shortest",
"-pix_fmt", "yuv420p", "-pix_fmt",
"yuv420p",
str(output_path), str(output_path),
] ]
else: else:
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg",
"-i", str(webm_path), "-y",
"-vf", subtitle_filter, "-i",
"-c:v", "libx264", str(capture_path),
"-preset", "fast", "-vf",
"-crf", "23", subtitle_filter,
"-c:a", "aac", "-c:v",
"-b:a", "128k", "libx264",
"-pix_fmt", "yuv420p", "-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-pix_fmt",
"yuv420p",
str(output_path), str(output_path),
] ]
return _run_ffmpeg(cmd) return _run_ffmpeg(cmd)
class RpaVideoSession: class RpaVideoSession:
"""Service/RPA 层统一录屏会话OPENCLAW_RECORD_VIDEO=1 时启用完整成片流程。""" """Service/RPA 层统一 ffmpeg 桌面录屏OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
def __init__( def __init__(
self, self,
@@ -183,6 +221,8 @@ class RpaVideoSession:
self.enabled = _record_video_enabled() self.enabled = _record_video_enabled()
self.warnings: List[str] = [] self.warnings: List[str] = []
self.output_video_path: Optional[str] = None self.output_video_path: Optional[str] = None
self.capture_path: Optional[str] = None
# 已废弃:勿用于 Playwright始终为 None。
self.record_video_dir: Optional[str] = None self.record_video_dir: Optional[str] = None
self.artifacts: Dict[str, Any] = {} self.artifacts: Dict[str, Any] = {}
@@ -194,24 +234,27 @@ class RpaVideoSession:
self._artifact_root = os.path.join( self._artifact_root = os.path.join(
self.skill_data_dir, "rpa-artifacts", batch_id self.skill_data_dir, "rpa-artifacts", batch_id
) )
self._raw_video_dir = os.path.join(self._artifact_root, "raw-video")
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles") self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
self._logs_dir = os.path.join(self._artifact_root, "logs") self._logs_dir = os.path.join(self._artifact_root, "logs")
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._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._steps: List[_StepEntry] = [] self._steps: List[_StepEntry] = []
self._t0: Optional[float] = None self._t0: Optional[float] = None
self._ffmpeg_proc: Optional[subprocess.Popen] = None
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._raw_video_dir, exist_ok=True)
os.makedirs(self._subtitles_dir, exist_ok=True) os.makedirs(self._subtitles_dir, exist_ok=True)
os.makedirs(self._logs_dir, exist_ok=True) os.makedirs(self._logs_dir, exist_ok=True)
self.record_video_dir = self._raw_video_dir os.makedirs(self._artifact_root, exist_ok=True)
def step(self, text: str, *, duration: float = 4.0) -> None: def step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(中文说明)。""" self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(相对录屏开始时间)。"""
if not self.enabled: if not self.enabled:
return return
if self._t0 is None: if self._t0 is None:
@@ -219,71 +262,159 @@ class RpaVideoSession:
elapsed = time.monotonic() - self._t0 elapsed = time.monotonic() - self._t0
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration)) self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
def _start_ffmpeg_capture(self) -> None:
if not self.enabled:
return
if not shutil.which("ffmpeg"):
self.warnings.append("ffmpeg_not_found")
return
cmd = _build_desktop_record_cmd(self._capture_path)
if cmd is None:
self.warnings.append(
"ffmpeg_record_start_failed: desktop capture only supported on Windows"
)
return
try:
self._ffmpeg_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.capture_path = self._capture_path
except Exception as exc:
self.warnings.append(f"ffmpeg_record_start_failed: {exc}")
self._ffmpeg_proc = None
def _stop_ffmpeg_capture(self) -> None:
proc = self._ffmpeg_proc
if proc is None:
return
if proc.poll() is None:
try:
if proc.stdin:
proc.stdin.write(b"q")
proc.stdin.flush()
except Exception:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
self._ffmpeg_proc = None
await_sleep = 0.3
time.sleep(await_sleep)
async def __aenter__(self) -> "RpaVideoSession": async def __aenter__(self) -> "RpaVideoSession":
if self.enabled: if self.enabled:
self._t0 = time.monotonic() self._t0 = time.monotonic()
if self.title: if self.title:
self.step(self.title, duration=3.0) self.add_step(self.title, duration=3.0)
self._start_ffmpeg_capture()
return self return self
async def __aexit__(self, exc_type, exc, tb) -> None: async def __aexit__(self, exc_type, exc, tb) -> None:
if self.enabled: if self.enabled:
self._stop_ffmpeg_capture()
await self.finalize() await self.finalize()
async def finalize(self) -> None: async def finalize(self) -> None:
"""在 Playwright context 已 close 后合成最终 MP4失败只记 warning。""" """停止录制后合成最终 MP4失败只记 warning,不抛异常"""
if not self.enabled: if not self.enabled:
self.artifacts = self.summary()
return return
if not self._steps and self.title: if not self._steps and self.title:
self.step(self.title) self.add_step(self.title)
try: try:
await asyncio.sleep(0.5) capture = Path(self._capture_path)
webm = _find_raw_webm(self._raw_video_dir) if not capture.is_file() or capture.stat().st_size <= 0:
if webm is None: self.warnings.append("ffmpeg_capture_missing")
self.warnings.append("no_playwright_raw_video") self.artifacts = self.summary()
self.artifacts = self.to_artifact_dict()
return return
self.capture_path = str(capture.resolve())
entries = list(self._steps) entries = list(self._steps)
if entries: if entries:
entries[-1].duration = max(entries[-1].duration, 5.0) entries[-1].duration = max(entries[-1].duration, 5.0)
_generate_srt(entries, self._srt_path) try:
_generate_srt(entries, self._srt_path)
except Exception as exc:
self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file() music = _resolve_music_file()
if music is None: if music is None:
self.warnings.append("background_music_missing") self.warnings.append("background_music_missing")
ok, err = _compose_webm_mp4( srt_file = Path(self._srt_path)
webm, use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
Path(self._srt_path),
ok, err = _compose_capture_mp4(
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output), Path(self._planned_output),
music_file=music, music_file=music,
) )
if not ok and music is not None: if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}") self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_webm_mp4( ok, err = _compose_capture_mp4(
webm, capture,
Path(self._srt_path), srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output), Path(self._planned_output),
music_file=None, music_file=None,
) )
if not ok and not use_srt:
ok, err = _run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(capture),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
str(self._planned_output),
]
)
if not ok: if not ok:
self.warnings.append(f"ffmpeg_compose_failed: {err}") self.warnings.append(f"ffmpeg_compose_failed: {err}")
self.artifacts = self.to_artifact_dict() self.artifacts = self.summary()
return return
self.output_video_path = str(Path(self._planned_output).resolve()) self.output_video_path = str(Path(self._planned_output).resolve())
self.artifacts = self.to_artifact_dict()
except Exception as exc: except Exception as exc:
self.warnings.append(f"video_finalize_error: {exc}") self.warnings.append(f"video_finalize_error: {exc}")
self.artifacts = self.to_artifact_dict()
def to_artifact_dict(self) -> Dict[str, Any]: self.artifacts = self.summary()
def summary(self) -> Dict[str, Any]:
return { return {
"enabled": self.enabled, "enabled": self.enabled,
"path": self.output_video_path, "path": self.output_video_path,
"capture_path": self.capture_path,
"warnings": list(self.warnings), "warnings": list(self.warnings),
"batch_id": self.batch_id,
} }
def to_artifact_dict(self) -> Dict[str, Any]:
data = self.summary()
data["batch_id"] = self.batch_id
return data

View File

@@ -50,14 +50,13 @@ class SimRpaAdapter(AdapterBase):
batch_id=batch_id, batch_id=batch_id,
title="技能仿真演示", title="技能仿真演示",
) as video: ) as video:
video.step("正在打开目标页面") video.add_step("正在打开目标页面")
try: try:
async with async_playwright() as pw: async with async_playwright() as pw:
context = await launch_persistent_browser( context = await launch_persistent_browser(
pw, pw,
profile_dir=profile_dir, profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"), headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
record_video_dir=video.record_video_dir,
) )
page = context.pages[0] if context.pages else await context.new_page() page = context.pages[0] if context.pages else await context.new_page()
await page.goto(url, wait_until="domcontentloaded", timeout=60_000) await page.goto(url, wait_until="domcontentloaded", timeout=60_000)