chore: sync RpaVideoSession standard and default OPENCLAW_RECORD_VIDEO=1
All checks were successful
技能自动化发布 / release (push) Successful in 4s
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=失败截图(默认)
|
||||
|
||||
# ── 节流 / 超时 ──
|
||||
|
||||
@@ -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=失败截图(默认)
|
||||
|
||||
# ── 节流 / 超时 ──
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
289
scripts/jiangchang_skill_core/rpa/video_session.py
Normal file
289
scripts/jiangchang_skill_core/rpa/video_session.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user