chore: sync RpaVideoSession standard and default OPENCLAW_RECORD_VIDEO=1
All checks were successful
技能自动化发布 / release (push) Successful in 4s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 13:29:50 +08:00
parent 685ad20bd2
commit 38343d22ca
6 changed files with 360 additions and 50 deletions

View File

@@ -12,7 +12,7 @@ OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 存证 ──
OPENCLAW_RECORD_VIDEO=0 # 1=全程录屏(合规场景建议开)
OPENCLAW_RECORD_VIDEO=1
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──

View File

@@ -29,7 +29,7 @@ OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 存证 ──
OPENCLAW_RECORD_VIDEO=0 # 1=全程录屏(合规场景建议开
OPENCLAW_RECORD_VIDEO=1 # 1=录屏+字幕+背景音+MP4成片RPA技能默认开见 RPA.md
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──

View File

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

View File

@@ -10,6 +10,11 @@ try:
except ImportError:
launch_persistent_browser = None # type: ignore[misc, assignment]
try:
from .video_session import RpaVideoSession
except ImportError:
RpaVideoSession = None # type: ignore[misc, assignment]
__all__ = [
"anti_detect",
"artifacts",
@@ -17,6 +22,7 @@ __all__ = [
"human_login",
"stealth",
"launch_persistent_browser",
"RpaVideoSession",
# re-export common anti_detect helpers
"random_delay",
"human_delay_short",

View File

@@ -0,0 +1,289 @@
"""RPA 运行录屏会话Playwright raw video → 字幕 + 背景音乐 → 最终 MP4。"""
from __future__ import annotations
import asyncio
import os
import random
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from .. import config
# 与 screencast/composer.py 保持一致的字幕样式
_JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
"Outline=2,Shadow=1,Alignment=2"
)
_DEFAULT_MUSIC_ROOT = os.environ.get(
"MEDIA_ASSETS_ROOT",
r"D:\OpenClaw\client-commons\media-assets",
)
def _record_video_enabled() -> bool:
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
def _resolve_music_file() -> Optional[Path]:
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
if not music_dir.is_dir():
return None
files = list(music_dir.rglob("*.mp3"))
if not files:
return None
return random.choice(files)
@dataclass
class _StepEntry:
start: float
text: str
duration: float = 4.0
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
"""根据步骤时间线生成 SRT防重叠"""
MIN_DURATION = 1.0
GAP = 0.1
steps = sorted(entries, key=lambda e: e.start)
if not steps:
steps = [_StepEntry(0.0, "任务执行中", 4.0)]
for i in range(len(steps) - 1):
cur, nxt = steps[i], steps[i + 1]
max_end = nxt.start - GAP
new_dur = max_end - cur.start
if new_dur < MIN_DURATION:
cur.duration = MIN_DURATION
nxt.start = cur.start + MIN_DURATION + GAP
else:
cur.duration = min(cur.duration, new_dur)
def _fmt(s: float) -> str:
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
ms = int((s % 1) * 1000)
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
for i, e in enumerate(steps, 1):
f.write(f"{i}\n")
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
f.write(f"{e.text}\n\n")
def _escape_srt_path(path: str) -> str:
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]:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError:
return False, "ffmpeg not found"
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
return False, err[:500] if err else f"exit {result.returncode}"
return True, ""
def _compose_webm_mp4(
webm_path: Path,
srt_path: Path,
output_path: Path,
*,
music_file: Optional[Path] = None,
music_volume: float = 0.15,
) -> tuple[bool, str]:
output_path.parent.mkdir(parents=True, exist_ok=True)
srt_esc = _escape_srt_path(str(srt_path.resolve()))
subtitle_filter = (
f"subtitles='{srt_esc}'"
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
)
if music_file and music_file.is_file():
filter_complex = (
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]"
)
cmd = [
"ffmpeg", "-y",
"-i", str(webm_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:
cmd = [
"ffmpeg", "-y",
"-i", str(webm_path),
"-vf", subtitle_filter,
"-c:v", "libx264",
"-preset", "fast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k",
"-pix_fmt", "yuv420p",
str(output_path),
]
return _run_ffmpeg(cmd)
class RpaVideoSession:
"""Service/RPA 层统一录屏会话OPENCLAW_RECORD_VIDEO=1 时启用完整成片流程。"""
def __init__(
self,
*,
skill_slug: str,
skill_data_dir: str,
batch_id: str,
title: str = "",
) -> None:
self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id
self.title = title
self.enabled = _record_video_enabled()
self.warnings: List[str] = []
self.output_video_path: Optional[str] = None
self.record_video_dir: Optional[str] = None
self.artifacts: Dict[str, Any] = {}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_batch = "".join(c if c.isalnum() or c in "-_" else "_" for c in batch_id)
self._video_stem = f"{skill_slug}_{ts}_{safe_batch}"
self._videos_dir = os.path.join(self.skill_data_dir, "videos")
self._artifact_root = os.path.join(
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._logs_dir = os.path.join(self._artifact_root, "logs")
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._steps: List[_StepEntry] = []
self._t0: Optional[float] = None
if self.enabled:
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._logs_dir, exist_ok=True)
self.record_video_dir = self._raw_video_dir
def step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(中文说明)。"""
if not self.enabled:
return
if self._t0 is None:
self._t0 = time.monotonic()
elapsed = time.monotonic() - self._t0
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
async def __aenter__(self) -> "RpaVideoSession":
if self.enabled:
self._t0 = time.monotonic()
if self.title:
self.step(self.title, duration=3.0)
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if self.enabled:
await self.finalize()
async def finalize(self) -> None:
"""在 Playwright context 已 close 后合成最终 MP4失败只记 warning。"""
if not self.enabled:
return
if not self._steps and self.title:
self.step(self.title)
try:
await asyncio.sleep(0.5)
webm = _find_raw_webm(self._raw_video_dir)
if webm is None:
self.warnings.append("no_playwright_raw_video")
self.artifacts = self.to_artifact_dict()
return
entries = list(self._steps)
if entries:
entries[-1].duration = max(entries[-1].duration, 5.0)
_generate_srt(entries, self._srt_path)
music = _resolve_music_file()
if music is None:
self.warnings.append("background_music_missing")
ok, err = _compose_webm_mp4(
webm,
Path(self._srt_path),
Path(self._planned_output),
music_file=music,
)
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_webm_mp4(
webm,
Path(self._srt_path),
Path(self._planned_output),
music_file=None,
)
if not ok:
self.warnings.append(f"ffmpeg_compose_failed: {err}")
self.artifacts = self.to_artifact_dict()
return
self.output_video_path = str(Path(self._planned_output).resolve())
self.artifacts = self.to_artifact_dict()
except Exception as exc:
self.warnings.append(f"video_finalize_error: {exc}")
self.artifacts = self.to_artifact_dict()
def to_artifact_dict(self) -> Dict[str, Any]:
return {
"enabled": self.enabled,
"path": self.output_video_path,
"warnings": list(self.warnings),
"batch_id": self.batch_id,
}

View File

@@ -1,13 +1,15 @@
"""仿真 RPA 档位:演示共享库 browser / anti_detect 用法(需浏览器,默认开发档)。"""
"""仿真 RPA 档位:演示 RpaVideoSession + launch_persistent_browser(需浏览器,默认开发档)。"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import anti_detect, artifacts, errors, launch_persistent_browser
from jiangchang_skill_core.rpa.human_login import wait_for_captcha_pass
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_data_dir, get_skill_root
@@ -26,16 +28,11 @@ class SimRpaAdapter(AdapterBase):
config.ensure_env_file(SKILL_SLUG, example_path)
url = config.get("TARGET_BASE_URL") or target or "https://example.com"
batch_id = "sim-demo"
batch_id = f"sim_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
data_dir = get_skill_data_dir()
profile_dir = os.path.join(data_dir, "browser-profile")
os.makedirs(profile_dir, exist_ok=True)
record_video = config.get_bool("OPENCLAW_RECORD_VIDEO")
video_dir = os.path.join(data_dir, "rpa-artifacts", batch_id, "video") if record_video else None
if video_dir:
os.makedirs(video_dir, exist_ok=True)
try:
from playwright.async_api import async_playwright
except ImportError:
@@ -47,51 +44,66 @@ class SimRpaAdapter(AdapterBase):
)
artifact_paths: dict = {}
try:
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
record_video_dir=video_dir,
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
# 演示拟人输入:若页面有 search 输入框则尝试输入
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
demo_text = items[0].label if items else "openclaw-demo"
await anti_detect.human_type(page, search, demo_text)
await anti_detect.human_delay_short()
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(page, data_dir, batch_id, "captcha")
if shot:
artifact_paths["captcha"] = shot
return ExampleResult(
ok=False,
serial_no=None,
error_msg=errors.ERR_CAPTCHA_NEED_HUMAN,
artifacts=artifact_paths,
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="技能仿真演示",
) as video:
video.step("正在打开目标页面")
try:
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
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()
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
await context.close()
except Exception as exc:
return ExampleResult(
ok=False,
serial_no=None,
error_msg=str(exc),
artifacts=artifact_paths,
)
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
demo_text = items[0].label if items else "openclaw-demo"
video.step("正在填写演示内容")
await anti_detect.human_type(page, search, demo_text)
await anti_detect.human_delay_short()
video.step("正在等待人工验证(如有)")
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(
page, data_dir, batch_id, "captcha"
)
if shot:
artifact_paths["captcha"] = shot
return ExampleResult(
ok=False,
serial_no=None,
error_msg=errors.ERR_CAPTCHA_NEED_HUMAN,
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
video.step("演示任务完成")
await context.close()
except Exception as exc:
video.step("演示异常结束")
return ExampleResult(
ok=False,
serial_no=None,
error_msg=str(exc),
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
arts = {"mode": "sim_rpa", "url": url, **artifact_paths, **video.to_artifact_dict()}
if video.output_video_path:
arts["video_path"] = video.output_video_path
return ExampleResult(
ok=True,
serial_no=f"SIM-{len(items)}",
error_msg=None,
artifacts={"mode": "sim_rpa", "url": url, **artifact_paths},
artifacts=arts,
)