Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7163f12a5 | |||
| f52536bdcc |
@@ -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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 非空则开录屏。
|
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 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():
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
"""RPA 运行录屏会话:Playwright raw video → 字幕 + 背景音乐 → 最终 MP4。"""
|
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
|
||||||
|
|
||||||
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
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, IO, 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 +27,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 +52,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 +87,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 +104,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 +148,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,7 +218,7 @@ 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.record_video_dir: Optional[str] = None
|
self.capture_path: Optional[str] = None
|
||||||
self.artifacts: Dict[str, Any] = {}
|
self.artifacts: Dict[str, Any] = {}
|
||||||
|
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
@@ -194,24 +229,44 @@ 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._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
|
||||||
|
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
|
||||||
|
self._ffmpeg_record_log_file: Optional[IO[str]] = 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 _record_log_hint(self) -> str:
|
||||||
|
if self.enabled and self._ffmpeg_record_log_path:
|
||||||
|
return f"; record_log={self._ffmpeg_record_log_path}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _append_warning(self, code: str, detail: str = "") -> None:
|
||||||
|
msg = code if not detail else f"{code}: {detail}"
|
||||||
|
if code in (
|
||||||
|
"ffmpeg_record_start_failed",
|
||||||
|
"ffmpeg_capture_missing",
|
||||||
|
"ffmpeg_compose_failed",
|
||||||
|
):
|
||||||
|
msg += self._record_log_hint()
|
||||||
|
self.warnings.append(msg)
|
||||||
|
|
||||||
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 +274,188 @@ 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 _close_ffmpeg_record_log(self) -> None:
|
||||||
|
fh = self._ffmpeg_record_log_file
|
||||||
|
self._ffmpeg_record_log_file = None
|
||||||
|
if fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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._append_warning(
|
||||||
|
"ffmpeg_record_start_failed",
|
||||||
|
"desktop capture only supported on Windows",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stderr_target: Any = subprocess.DEVNULL
|
||||||
|
try:
|
||||||
|
os.makedirs(self._logs_dir, exist_ok=True)
|
||||||
|
self._ffmpeg_record_log_file = open(
|
||||||
|
self._ffmpeg_record_log_path, "a", encoding="utf-8"
|
||||||
|
)
|
||||||
|
stderr_target = self._ffmpeg_record_log_file
|
||||||
|
except Exception as exc:
|
||||||
|
self.warnings.append(f"ffmpeg_record_log_open_failed: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ffmpeg_proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=stderr_target,
|
||||||
|
)
|
||||||
|
self.capture_path = self._capture_path
|
||||||
|
except Exception as exc:
|
||||||
|
self._append_warning("ffmpeg_record_start_failed", str(exc))
|
||||||
|
self._ffmpeg_proc = None
|
||||||
|
self._close_ffmpeg_record_log()
|
||||||
|
|
||||||
|
def _stop_ffmpeg_capture(self) -> None:
|
||||||
|
proc = self._ffmpeg_proc
|
||||||
|
if proc is None:
|
||||||
|
self._close_ffmpeg_record_log()
|
||||||
|
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
|
||||||
|
self._close_ffmpeg_record_log()
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
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._append_warning("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)
|
||||||
|
try:
|
||||||
_generate_srt(entries, self._srt_path)
|
_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._append_warning("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]:
|
||||||
|
record_log = (
|
||||||
|
os.path.abspath(self._ffmpeg_record_log_path)
|
||||||
|
if self.enabled and self._ffmpeg_record_log_path
|
||||||
|
else None
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"path": self.output_video_path,
|
"path": self.output_video_path,
|
||||||
|
"capture_path": self.capture_path,
|
||||||
|
"record_log_path": record_log,
|
||||||
"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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
统一文件日志:{DATA_ROOT}/{USER_ID}/logs/jiangchang.log
|
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
|
||||||
按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。
|
按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -20,21 +20,29 @@ _logger_name: str = ""
|
|||||||
|
|
||||||
def get_unified_logs_dir() -> str:
|
def get_unified_logs_dir() -> str:
|
||||||
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
||||||
|
try:
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_skill_log_file_path() -> str:
|
def get_skill_log_file_path() -> str:
|
||||||
|
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
|
||||||
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
|
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
|
||||||
if override:
|
if override:
|
||||||
parent = os.path.dirname(os.path.abspath(override))
|
parent = os.path.dirname(os.path.abspath(override))
|
||||||
if parent:
|
if parent:
|
||||||
|
try:
|
||||||
os.makedirs(parent, exist_ok=True)
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return os.path.abspath(override)
|
return os.path.abspath(override)
|
||||||
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
|
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
|
||||||
|
|
||||||
|
|
||||||
def ensure_trace_for_process() -> str:
|
def ensure_trace_for_process() -> str:
|
||||||
|
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
||||||
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||||
if existing:
|
if existing:
|
||||||
return existing
|
return existing
|
||||||
@@ -44,8 +52,11 @@ def ensure_trace_for_process() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
|
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
|
||||||
|
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
|
||||||
ensure_trace_for_process()
|
ensure_trace_for_process()
|
||||||
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or ensure_trace_for_process()
|
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||||
|
if not tid:
|
||||||
|
tid = ensure_trace_for_process()
|
||||||
base = os.environ if environ is None else environ
|
base = os.environ if environ is None else environ
|
||||||
return {**base, "JIANGCHANG_TRACE_ID": tid}
|
return {**base, "JIANGCHANG_TRACE_ID": tid}
|
||||||
|
|
||||||
@@ -57,7 +68,9 @@ class _SkillContextFilter(logging.Filter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
_FORMAT = "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
_FORMAT = (
|
||||||
|
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
||||||
|
)
|
||||||
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
|
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +87,17 @@ def _backup_count() -> int:
|
|||||||
return 30
|
return 30
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_fallback_handler(log: logging.Logger, fmt: logging.Formatter) -> None:
|
||||||
|
"""文件日志不可用时仅挂内存 handler,不向 stderr 打印 logging 异常。"""
|
||||||
|
log.addHandler(logging.NullHandler())
|
||||||
|
|
||||||
|
|
||||||
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
||||||
|
"""
|
||||||
|
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
||||||
|
须在进程早期调用(如 CLI main 在业务日志之前)。
|
||||||
|
文件日志目录无权限或不可写时降级为 no-op,不影响 CLI 返回码。
|
||||||
|
"""
|
||||||
global _skill_slug, _logger_name
|
global _skill_slug, _logger_name
|
||||||
ensure_trace_for_process()
|
ensure_trace_for_process()
|
||||||
_skill_slug = skill_slug
|
_skill_slug = skill_slug
|
||||||
@@ -84,6 +107,10 @@ def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
|||||||
if log.handlers:
|
if log.handlers:
|
||||||
return
|
return
|
||||||
log.setLevel(_log_level_from_env())
|
log.setLevel(_log_level_from_env())
|
||||||
|
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||||
|
filt = _SkillContextFilter()
|
||||||
|
|
||||||
|
try:
|
||||||
path = get_skill_log_file_path()
|
path = get_skill_log_file_path()
|
||||||
fh = TimedRotatingFileHandler(
|
fh = TimedRotatingFileHandler(
|
||||||
path,
|
path,
|
||||||
@@ -93,16 +120,28 @@ def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
delay=True,
|
delay=True,
|
||||||
)
|
)
|
||||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
|
||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
fh.addFilter(_SkillContextFilter())
|
fh.addFilter(filt)
|
||||||
log.addHandler(fh)
|
log.addHandler(fh)
|
||||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ("1", "true", "yes", "on"):
|
except (OSError, PermissionError, ValueError):
|
||||||
|
_attach_fallback_handler(log, fmt)
|
||||||
|
except Exception:
|
||||||
|
_attach_fallback_handler(log, fmt)
|
||||||
|
|
||||||
|
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
):
|
||||||
|
try:
|
||||||
sh = logging.StreamHandler(sys.stderr)
|
sh = logging.StreamHandler(sys.stderr)
|
||||||
sh.setLevel(logging.WARNING)
|
sh.setLevel(logging.WARNING)
|
||||||
sh.setFormatter(fmt)
|
sh.setFormatter(fmt)
|
||||||
sh.addFilter(_SkillContextFilter())
|
sh.addFilter(filt)
|
||||||
log.addHandler(sh)
|
log.addHandler(sh)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
log.propagate = False
|
log.propagate = False
|
||||||
|
|
||||||
|
|
||||||
@@ -119,19 +158,27 @@ def attach_unified_file_handler(
|
|||||||
logger_name: str,
|
logger_name: str,
|
||||||
level: int = logging.DEBUG,
|
level: int = logging.DEBUG,
|
||||||
) -> logging.Logger:
|
) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
||||||
|
"""
|
||||||
global _skill_slug
|
global _skill_slug
|
||||||
ensure_trace_for_process()
|
ensure_trace_for_process()
|
||||||
_skill_slug = skill_slug
|
_skill_slug = skill_slug
|
||||||
lg = logging.getLogger(logger_name)
|
lg = logging.getLogger(logger_name)
|
||||||
lg.handlers.clear()
|
lg.handlers.clear()
|
||||||
lg.setLevel(level)
|
lg.setLevel(level)
|
||||||
|
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||||
|
try:
|
||||||
parent = os.path.dirname(os.path.abspath(log_path))
|
parent = os.path.dirname(os.path.abspath(log_path))
|
||||||
if parent:
|
if parent:
|
||||||
os.makedirs(parent, exist_ok=True)
|
os.makedirs(parent, exist_ok=True)
|
||||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
|
||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
fh.addFilter(_SkillContextFilter())
|
fh.addFilter(_SkillContextFilter())
|
||||||
lg.addHandler(fh)
|
lg.addHandler(fh)
|
||||||
|
except (OSError, PermissionError, ValueError):
|
||||||
|
lg.addHandler(logging.NullHandler())
|
||||||
|
except Exception:
|
||||||
|
lg.addHandler(logging.NullHandler())
|
||||||
lg.propagate = False
|
lg.propagate = False
|
||||||
return lg
|
return lg
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user