chore: sync ffmpeg RpaVideoSession, remove Playwright recording
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -159,10 +159,11 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
|
||||
|
||||
- **路径**:`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
|
||||
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
|
||||
- **运行录屏成片**: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/`。
|
||||
- **运行录屏成片**:RPA 型技能默认 `OPENCLAW_RECORD_VIDEO=1`,表示 **ffmpeg 录制运行过程** + 字幕 + 背景音乐 + 最终 MP4 **一体开启**。由 `RpaVideoSession` 统一触发,不在 CLI/E2E 单独实现。
|
||||
- **Playwright 不负责录屏**,仅浏览器自动化。
|
||||
- **ffmpeg 是唯一录屏器**(Windows:`gdigrab` + `desktop`)。
|
||||
- **最终视频**:`{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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""统一 persistent context 启动封装。"""
|
||||
"""统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,12 +24,11 @@ async def launch_persistent_browser(
|
||||
executable_path: str | None = None,
|
||||
headless: bool | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
record_video_dir: str | None = None,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
record_video_dir 非空则开录屏。
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
"""
|
||||
if headless is None:
|
||||
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:
|
||||
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)
|
||||
if stealth_enabled():
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""RPA 运行录屏会话:Playwright raw video → 字幕 + 背景音乐 → 最终 MP4。"""
|
||||
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。
|
||||
|
||||
Playwright 仅用于浏览器自动化,不负责录屏。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -15,7 +19,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from .. import config
|
||||
|
||||
# 与 screencast/composer.py 保持一致的字幕样式
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"FontName=Arial,FontSize=14,"
|
||||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||
@@ -27,6 +30,8 @@ _DEFAULT_MUSIC_ROOT = os.environ.get(
|
||||
r"D:\OpenClaw\client-commons\media-assets",
|
||||
)
|
||||
|
||||
_CAPTURE_FRAMERATE = 15
|
||||
|
||||
|
||||
def _record_video_enabled() -> bool:
|
||||
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:
|
||||
"""根据步骤时间线生成 SRT(防重叠)。"""
|
||||
MIN_DURATION = 1.0
|
||||
GAP = 0.1
|
||||
steps = sorted(entries, key=lambda e: e.start)
|
||||
@@ -86,16 +90,6 @@ 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(
|
||||
@@ -113,8 +107,31 @@ def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _compose_webm_mp4(
|
||||
webm_path: Path,
|
||||
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
|
||||
"""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,
|
||||
output_path: Path,
|
||||
*,
|
||||
@@ -134,39 +151,60 @@ def _compose_webm_mp4(
|
||||
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",
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_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",
|
||||
"-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",
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_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 时启用完整成片流程。"""
|
||||
"""Service/RPA 层统一 ffmpeg 桌面录屏;OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -183,6 +221,8 @@ class RpaVideoSession:
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
self.capture_path: Optional[str] = None
|
||||
# 已废弃:勿用于 Playwright;始终为 None。
|
||||
self.record_video_dir: Optional[str] = None
|
||||
self.artifacts: Dict[str, Any] = {}
|
||||
|
||||
@@ -194,24 +234,27 @@ class RpaVideoSession:
|
||||
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._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._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
|
||||
|
||||
self._steps: List[_StepEntry] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._ffmpeg_proc: Optional[subprocess.Popen] = 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
|
||||
os.makedirs(self._artifact_root, exist_ok=True)
|
||||
|
||||
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:
|
||||
return
|
||||
if self._t0 is None:
|
||||
@@ -219,71 +262,159 @@ class RpaVideoSession:
|
||||
elapsed = time.monotonic() - self._t0
|
||||
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":
|
||||
if self.enabled:
|
||||
self._t0 = time.monotonic()
|
||||
if self.title:
|
||||
self.step(self.title, duration=3.0)
|
||||
self.add_step(self.title, duration=3.0)
|
||||
self._start_ffmpeg_capture()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if self.enabled:
|
||||
self._stop_ffmpeg_capture()
|
||||
await self.finalize()
|
||||
|
||||
async def finalize(self) -> None:
|
||||
"""在 Playwright context 已 close 后合成最终 MP4;失败只记 warning。"""
|
||||
"""停止录制后合成最终 MP4;失败只记 warning,不抛异常。"""
|
||||
if not self.enabled:
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
if not self._steps and self.title:
|
||||
self.step(self.title)
|
||||
self.add_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()
|
||||
capture = Path(self._capture_path)
|
||||
if not capture.is_file() or capture.stat().st_size <= 0:
|
||||
self.warnings.append("ffmpeg_capture_missing")
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
self.capture_path = str(capture.resolve())
|
||||
|
||||
entries = list(self._steps)
|
||||
if entries:
|
||||
entries[-1].duration = max(entries[-1].duration, 5.0)
|
||||
try:
|
||||
_generate_srt(entries, self._srt_path)
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"subtitle_write_failed: {exc}")
|
||||
|
||||
music = _resolve_music_file()
|
||||
if music is None:
|
||||
self.warnings.append("background_music_missing")
|
||||
|
||||
ok, err = _compose_webm_mp4(
|
||||
webm,
|
||||
Path(self._srt_path),
|
||||
srt_file = Path(self._srt_path)
|
||||
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||
|
||||
ok, err = _compose_capture_mp4(
|
||||
capture,
|
||||
srt_file if use_srt else 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),
|
||||
ok, err = _compose_capture_mp4(
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
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:
|
||||
self.warnings.append(f"ffmpeg_compose_failed: {err}")
|
||||
self.artifacts = self.to_artifact_dict()
|
||||
self.artifacts = self.summary()
|
||||
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]:
|
||||
self.artifacts = self.summary()
|
||||
|
||||
def summary(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"path": self.output_video_path,
|
||||
"capture_path": self.capture_path,
|
||||
"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
|
||||
|
||||
@@ -50,14 +50,13 @@ class SimRpaAdapter(AdapterBase):
|
||||
batch_id=batch_id,
|
||||
title="技能仿真演示",
|
||||
) as video:
|
||||
video.step("正在打开目标页面")
|
||||
video.add_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)
|
||||
|
||||
Reference in New Issue
Block a user