15 Commits

Author SHA1 Message Date
1c51139883 add skill activity stream and job runtime foundation (v1.1.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Introduce SAS v1 emit/finish/journal API, sibling CLI bridge, RPA video session integration, and tests for the host Job Runner activity timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:12:51 +08:00
1632828372 统一运行时环境变量契约,移除 CLAW_* 别名 (v1.0.17)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
2026-06-30 14:41:38 +08:00
f7fc33fa4a fix(config): unify RPA runtime reads through config.get* (v1.0.16)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 21s
Route OPENCLAW_BROWSER_HEADLESS, OPENCLAW_PLAYWRIGHT_STEALTH, OPENCLAW_ARTIFACTS_ON_FAILURE, and OPENCLAW_RECORD_VIDEO (health diagnostics) through jiangchang_skill_core.config.get_bool so user .env and .env.example share the same three-tier priority as the rest of the skill stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 10:21:32 +08:00
5a0b93448c 升级版本到1.0.15
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
2026-06-27 14:53:59 +08:00
367df10755 chore: update skill release readme source
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 10:59:37 +08:00
857e7e3d73 feat: improve rpa video timing and narration
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:50:54 +08:00
0e2b73e1ad feat: improve rpa video audio composition
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 50s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:14:31 +08:00
36a411810d ci: publish formal version from pyproject.toml on main push
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
Remove .postN CI versioning, fix runtime version_ge for legacy post releases, and use ASCII issue separators in health output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 09:26:41 +08:00
c9398451d0 Release v1.0.11: shared runtime diagnostics and media_assets probe helpers
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 23s
2026-06-07 09:06:41 +08:00
2a542c0be6 ci: publish platform kit on main only
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s
2026-06-06 16:26:24 +08:00
e59661f2d8 fix(media-assets): download stable release bundle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:16:18 +08:00
434a405e19 Release v1.0.10: shared media_assets and screencast media_assets_root override
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 10:32:35 +08:00
aab0207aec fix: restore platform kit version to 1.0.9
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 19:44:30 +08:00
eba90fb8b9 feat: resolve shared media assets
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:43:58 +08:00
21d4907f02 fix: unified logging probe before file handler attach
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 36s
2026-06-02 15:18:38 +08:00
35 changed files with 5255 additions and 327 deletions

View File

@@ -4,9 +4,6 @@ on:
push: push:
branches: branches:
- main - main
tags:
- "v*"
workflow_dispatch:
jobs: jobs:
publish: publish:
@@ -25,13 +22,6 @@ jobs:
steps: steps:
- uses: https://git.jc2009.com/admin/actions-checkout@v4 - uses: https://git.jc2009.com/admin/actions-checkout@v4
- name: Resolve and patch package version
env:
GITHUB_REF: ${{ github.ref }}
GITHUB_RUN_NUMBER: ${{ github.run_number }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: python3.12 tools/ci_set_package_version.py
- name: Install build tools - name: Install build tools
run: python3.12 -m pip install --upgrade build twine --break-system-packages run: python3.12 -m pip install --upgrade build twine --break-system-packages

View File

@@ -108,6 +108,13 @@ jobs:
' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \ ' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \
'' \ '' \
"shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \ "shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \
"readme_src = os.path.abspath('README.md')" \
"readme_dst = os.path.join(PACKAGE, 'README.md')" \
'if os.path.isfile(readme_src):' \
" shutil.copy2(readme_src, readme_dst)" \
" print('Copied README.md')" \
'if os.path.isfile(readme_src) and not os.path.isfile(readme_dst):' \
" raise RuntimeError('README.md exists in skill root but was not packaged')" \
"copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \ "copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \ "copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \ "copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
@@ -144,7 +151,7 @@ jobs:
skill_readme_md = (post.content or '').strip() skill_readme_md = (post.content or '').strip()
skill_description = metadata.get('description') skill_description = metadata.get('description')
metadata['readme_md'] = skill_readme_md metadata['readme_md'] = skill_readme_md
readme_path = os.path.join('references', 'README.md') readme_path = 'README.md'
if os.path.isfile(readme_path): if os.path.isfile(readme_path):
try: try:
readme_post = frontmatter.load(readme_path) readme_post = frontmatter.load(readme_path)

149
README.md
View File

@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
## 3. 包含的模块 ## 3. 包含的模块
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析等,供 Skill CLI / 服务进程使用。 - **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**`emit` / `finish` / Run Journal、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`等,供 Skill CLI / 服务进程使用。
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright以便 `import playwright` 可用。 - **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright以便 `import playwright` 可用。
## 4. 快速开始 ## 4. 快速开始
@@ -56,7 +56,7 @@ finally:
### 桌面 E2E`jiangchang_desktop_sdk.e2e_helpers` ### 桌面 E2E`jiangchang_desktop_sdk.e2e_helpers`
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。 Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
推荐 preflight 流程: 推荐 preflight 流程:
@@ -78,17 +78,147 @@ assert_skill_env_file(ctx, ["API_KEY"])
send_prompt_via_composer(page, "第一行\n第二行") send_prompt_via_composer(page, "第一行\n第二行")
``` ```
## Skill Activity Stream / Job 运行时
长任务批量生成、RPA需要把**逐步进度**推到匠厂宿主 UI而不能依赖 Agent 轮询或 OpenClaw stdout patch。`jiangchang_skill_core.activity` 提供 **Skill Activity Stream (SAS) v1** 契约:
| API | 作用 |
|-----|------|
| `emit(text, ...)` | 写 Run Journal`activity` / `progress` / `warn` / `debug` |
| `step(text)` | `emit(type='activity')` 别名 |
| `finish(status=..., **fields)` | 写终态事件 + **唯一**向 stdout 输出单行 result JSON |
| `rpa_step(name)` | 装饰器:进入/成功/失败自动 emit |
| `job_context(...)` | 可选包裹 main异常时自动 `finish(failed)` |
### Run Journal 路径(宿主 tail 此文件)
```
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
```
- `job_id`:优先 `JIANGCHANG_JOB_ID`,否则进程内 `uuid4().hex`(首次 emit 时生成并缓存)
- `JIANGCHANG_RUN_MODE``job` | `cli`(默认 `cli`),首行 meta 事件记录
- NDJSONUTF-8append-only每行 flush
- `debug` 类型仅当 `JIANGCHANG_ACTIVITY_DEBUG=1` 时写入
### 与 Agent / OpenClaw 的分工
- **`emit()` / journal**:永不 print 到 stdout避免 bash tool 结果淹没 Agent context
- **`finish()`**:唯一允许向 stdout 写业务结果的 API单行 compact JSON`status` + 摘要字段,默认 ≤4096 字符
- 技能里手写 `print("OK")`、pretty JSON 应逐步改为 `finish()`RPA 步骤用 `emit("打开浏览器")``@rpa_step`
### GEO batch 示例
```python
from jiangchang_skill_core.activity import emit, finish
for i, item in enumerate(items, 1):
emit(f"生成 {item['title']}", type="progress", current=i, total=len(items), skill="geo-batch")
# ... business logic ...
finish(status="success", skill="geo-batch", generated=len(items))
```
### RPA 步骤示例
```python
from jiangchang_skill_core.activity import emit, rpa_step
@rpa_step("打开浏览器")
def open_browser(page):
page.goto("https://example.com")
emit("手动步骤说明", skill="publish-toutiao", stage="rpa")
```
`RpaVideoSession.add_step()` 在录屏字幕之外默认同步 `emit(..., stage="rpa.video")`;可通过 `emit_activity=False` 关闭。
### 兄弟技能 CLI 桥
```python
from jiangchang_skill_core import call_sibling_json, get_sibling_main_path
accounts = call_sibling_json("account-manager", ["list", "all"])
```
同步 `subprocess.run``capture_output=True`,自动带上 `subprocess_env_with_trace()`
## 共享媒体资源
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
`{JIANGCHANG_DATA_ROOT}/shared/media-assets`
读取背景音乐、字体、水印和 ffmpeg 工具。
如果目录不存在,会优先下载稳定 release bundle不依赖用户电脑安装 Git
`https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip`
bundle 内含背景音乐、字体与水印;**不包含** ffmpeg 二进制。ffmpeg / ffprobe 仍由 bundle 内 `manifest.json``tools.ffmpeg` 下载源单独拉取。
可通过环境变量覆盖:
- `MEDIA_ASSETS_ROOT=/path/to/custom/media-assets` — 本地资源目录
- `MEDIA_ASSETS_BUNDLE_URL=https://example.com/media-assets.zip` — bundle 下载地址
### Runtime diagnostics统一 health 诊断)
`jiangchang_skill_core.runtime_diagnostics` 提供各 Skill 共用的 Runtime / media-assets / ffmpeg 探测避免每个技能各自复制检查逻辑。Skill 的 `health` 命令可调用:
```python
from jiangchang_skill_core import (
collect_runtime_diagnostics,
format_runtime_health_lines,
runtime_diagnostics_dict,
)
diag = collect_runtime_diagnostics(
skill_slug="my-skill",
platform_kit_min_version="1.0.10", # 可选;省略则只报告当前版本
skill_root="/path/to/my-skill", # 可选;用于检测 vendored jiangchang_skill_core
)
for line in format_runtime_health_lines(diag):
print(line)
print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
```
诊断只读探测本地状态,不会触发 media-assets 下载。
### 录屏合成(`screencast`
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。
- **默认**:不传 `media_assets_root` 时,使用 `{JIANGCHANG_DATA_ROOT}/shared/media-assets`(或进程环境中已有的 `MEDIA_ASSETS_ROOT`)。
- **覆盖**:传入 `media_assets_root="/path/to/media-assets"`,或在 `compose_video(..., media_assets_env={"MEDIA_ASSETS_ROOT": "..."})` 中设置;参数 `media_assets_root` 优先于 `media_assets_env` 里已有的 `MEDIA_ASSETS_ROOT`
- `run_screencast(media_assets_root=...)` 会把该路径原样传给 `compose_video()``music_subdir` 已废弃(会触发 `DeprecationWarning`),音乐选择统一由 `pick_background_music()` 完成。
```python
from screencast import run_screencast
run_screencast(
skill_slug="demo-skill",
subtitle_script=[("PASSED", "测试通过")],
pytest_args=["-q", "tests/test_demo.py"],
output_dir="./out",
media_assets_root="/path/to/media-assets", # 可选
)
```
## 5. 发版流程(维护者) ## 5. 发版流程(维护者)
### main 分支自动预发布 ### 发布规则
每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **唯一** 的 post 版本(基于 `pyproject.toml` 中的基础版本,例如 `0.1.0.post42`)。版本号不会写回仓库,仅在 CI 构建前临时改写 `pyproject.toml``jiangchang_desktop_sdk.__version__` - jiangchang-platform-kit **只发布正式版本**
- 每次需要发布公共能力时,先 bump `pyproject.toml``version`(例如 `1.0.10``1.0.11`)。
- 推送 `main` 后,`.github/workflows/publish.yml` 会构建并上传该正式版本到 Gitea PyPI。
- 发布包版本必须等于 `pyproject.toml` 中的 `version`;若该版本已存在,上传会失败,需继续 bump 版本号后再推送。
```bash ```bash
git push origin main git push origin main
``` ```
测试人员升级安装: 升级安装:
```bash ```bash
python -m pip install --upgrade \ python -m pip install --upgrade \
@@ -97,15 +227,6 @@ python -m pip install --upgrade \
jiangchang-platform-kit jiangchang-platform-kit
``` ```
### 正式版本tag
打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v0.1.1` → PyPI 包 `0.1.1`
```bash
git tag v0.1.1
git push origin v0.1.1
```
### 安装后验证 ### 安装后验证
```bash ```bash

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "0.1.0" version = "1.1.0"
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -23,7 +23,7 @@ __all__ = [
"LaunchError", "LaunchError",
"GatewayDownError", "GatewayDownError",
] ]
__version__ = "0.1.0" __version__ = "1.0.13"
def __getattr__(name: str): def __getattr__(name: str):

View File

@@ -1,3 +1,17 @@
from .activity import (
emit,
finish,
get_job_id,
get_run_mode,
job_context,
rpa_step,
step,
)
from .sibling_bridge import (
call_sibling_json,
call_sibling_json_array,
get_sibling_main_path,
)
from .client import EntitlementClient from .client import EntitlementClient
from .config import ensure_env_file, get, get_bool, get_float, get_int from .config import ensure_env_file, get, get_bool, get_float, get_int
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
@@ -5,8 +19,10 @@ from .guard import enforce_entitlement
from .models import EntitlementResult from .models import EntitlementResult
from .runtime_env import ( from .runtime_env import (
apply_cli_local_defaults, apply_cli_local_defaults,
ensure_runs_dir,
find_chrome_executable, find_chrome_executable,
get_data_root, get_data_root,
get_runs_dir,
get_sibling_skills_root, get_sibling_skills_root,
get_skills_root, get_skills_root,
get_user_id, get_user_id,
@@ -21,6 +37,30 @@ from .unified_logging import (
setup_skill_logging, setup_skill_logging,
subprocess_env_with_trace, subprocess_env_with_trace,
) )
from .media_assets import (
MediaAssetsStatus,
background_music_issue,
ensure_media_assets,
is_git_lfs_pointer,
is_usable_audio_file,
pick_background_music,
probe_background_music,
probe_ffmpeg,
probe_media_assets,
resolve_ffmpeg,
resolve_ffprobe,
resolve_media_assets_root,
resolve_music_dir,
)
from .runtime_diagnostics import (
RuntimeDiagnostics,
RuntimeIssue,
collect_runtime_diagnostics,
format_runtime_health_lines,
is_jiangchang_skill_core_from_skill_tree,
runtime_diagnostics_dict,
version_ge,
)
try: try:
from . import rpa from . import rpa
@@ -28,11 +68,43 @@ except ImportError:
rpa = None # type: ignore[assignment,misc] rpa = None # type: ignore[assignment,misc]
__all__ = [ __all__ = [
"call_sibling_json",
"call_sibling_json_array",
"emit",
"finish",
"get_job_id",
"get_run_mode",
"get_runs_dir",
"ensure_runs_dir",
"job_context",
"rpa_step",
"step",
"get_sibling_main_path",
"EntitlementClient", "EntitlementClient",
"EntitlementDeniedError", "EntitlementDeniedError",
"EntitlementError", "EntitlementError",
"EntitlementResult", "EntitlementResult",
"EntitlementServiceError", "EntitlementServiceError",
"MediaAssetsStatus",
"RuntimeDiagnostics",
"RuntimeIssue",
"background_music_issue",
"collect_runtime_diagnostics",
"ensure_media_assets",
"format_runtime_health_lines",
"is_git_lfs_pointer",
"is_jiangchang_skill_core_from_skill_tree",
"is_usable_audio_file",
"probe_background_music",
"probe_ffmpeg",
"probe_media_assets",
"pick_background_music",
"resolve_ffmpeg",
"resolve_ffprobe",
"resolve_media_assets_root",
"resolve_music_dir",
"runtime_diagnostics_dict",
"version_ge",
"apply_cli_local_defaults", "apply_cli_local_defaults",
"attach_unified_file_handler", "attach_unified_file_handler",
"ensure_env_file", "ensure_env_file",
@@ -44,6 +116,7 @@ __all__ = [
"get_data_root", "get_data_root",
"get_float", "get_float",
"get_int", "get_int",
"get_runs_dir",
"get_sibling_skills_root", "get_sibling_skills_root",
"get_skills_root", "get_skills_root",
"get_skill_log_file_path", "get_skill_log_file_path",

View File

@@ -0,0 +1,343 @@
"""
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
finish() is the only API that writes a single-line result JSON to stdout.
"""
from __future__ import annotations
import asyncio
import functools
import inspect
import json
import os
import sys
import time
import uuid
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional, TypeVar
from .runtime_env import ensure_runs_dir, get_runs_dir
_SCHEMA_VERSION = 1
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
_RESULT_MAX_CHARS = 4096
_cached_job_id: Optional[str] = None
_journal_path: Optional[str] = None
_meta_written: bool = False
_journal_file: Optional[Any] = None
F = TypeVar("F", bound=Callable[..., Any])
def _activity_debug_enabled() -> bool:
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
return v in ("1", "true", "yes", "on")
def get_run_mode() -> str:
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
if mode in ("job", "cli"):
return mode
return "cli"
def get_job_id() -> str:
global _cached_job_id
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
if env_job:
return env_job
if _cached_job_id is None:
_cached_job_id = uuid.uuid4().hex
return _cached_job_id
def _get_journal_path() -> str:
global _journal_path
if _journal_path is None:
ensure_runs_dir()
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
return _journal_path
def _open_journal() -> Any:
global _journal_file
if _journal_file is None:
path = _get_journal_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
_journal_file = open(path, "a", encoding="utf-8")
return _journal_file
def _write_journal_line(event: dict[str, Any]) -> None:
global _meta_written
f = _open_journal()
if not _meta_written:
meta = {
"type": "meta",
"schema_version": _SCHEMA_VERSION,
"job_id": get_job_id(),
"run_mode": get_run_mode(),
"ts": _now_ms(),
}
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
f.flush()
_meta_written = True
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
f.flush()
def _now_ms() -> int:
return int(time.time() * 1000)
def _build_event(
type_: str,
text: str,
*,
skill: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
item_id: str | None = None,
ts: int | None = None,
**extra: Any,
) -> dict[str, Any]:
cleaned = (text or "").strip()
if not cleaned:
raise ValueError("text must be non-empty")
event: dict[str, Any] = {
"type": type_,
"text": cleaned,
"schema_version": _SCHEMA_VERSION,
"ts": ts if ts is not None else _now_ms(),
}
if skill is not None:
event["skill"] = skill
if stage is not None:
event["stage"] = stage
if current is not None:
event["current"] = current
if total is not None:
event["total"] = total
if item_id is not None:
event["item_id"] = item_id
for key, value in extra.items():
if key not in event:
event[key] = value
return event
def emit(
text: str,
*,
type: str = "activity",
skill: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
item_id: str | None = None,
**extra: Any,
) -> None:
"""Append one SAS event to the Run Journal. Never writes to stdout."""
if type == "debug" and not _activity_debug_enabled():
return
if type not in _VALID_EMIT_TYPES:
raise ValueError(f"invalid emit type: {type!r}")
event = _build_event(
type,
text,
skill=skill,
stage=stage,
current=current,
total=total,
item_id=item_id,
**extra,
)
_write_journal_line(event)
def step(text: str, **kwargs: Any) -> None:
"""Shorthand for emit(type='activity')."""
emit(text, type="activity", **kwargs)
def _serialize_result(result: dict[str, Any]) -> str:
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
if len(line) <= _RESULT_MAX_CHARS:
return line
core_keys = ("type", "schema_version", "status", "skill", "message")
truncated: dict[str, Any] = {
k: result[k] for k in core_keys if k in result
}
truncated["truncated"] = True
for key, value in result.items():
if key in truncated or key == "truncated":
continue
candidate = {**truncated, key: value}
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
if len(candidate_line) <= _RESULT_MAX_CHARS:
truncated[key] = value
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
if len(line) > _RESULT_MAX_CHARS:
truncated.pop("message", None)
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
if len(line) > _RESULT_MAX_CHARS:
return line[:_RESULT_MAX_CHARS]
return line
def finish(
*,
status: str = "success",
message: str | None = None,
skill: str | None = None,
**fields: Any,
) -> None:
"""
1) Write terminal done|error event to Run Journal.
2) Print a single compact result JSON line to stdout (Agent/bash path).
"""
if status not in ("success", "partial", "failed"):
raise ValueError(f"invalid finish status: {status!r}")
journal_type = "error" if status == "failed" else "done"
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
journal_event = _build_event(
journal_type,
terminal_text,
skill=skill,
status=status,
**fields,
)
if message is not None:
journal_event["message"] = message
_write_journal_line(journal_event)
result: dict[str, Any] = {
"type": "result",
"schema_version": _SCHEMA_VERSION,
"status": status,
}
if skill is not None:
result["skill"] = skill
if message is not None:
result["message"] = message
result.update(fields)
line = _serialize_result(result)
sys.stdout.write(line + "\n")
sys.stdout.flush()
@contextmanager
def job_context(
*,
job_id: str | None = None,
skill: str | None = None,
run_mode: str | None = None,
) -> Iterator[None]:
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
global _cached_job_id, _journal_path, _meta_written, _journal_file
saved_job_id = _cached_job_id
saved_journal_path = _journal_path
saved_meta = _meta_written
saved_file = _journal_file
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
if job_id:
os.environ["JIANGCHANG_JOB_ID"] = job_id
_cached_job_id = None
_journal_path = None
_meta_written = False
if _journal_file is not None:
try:
_journal_file.close()
except Exception:
pass
_journal_file = None
if run_mode:
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
try:
yield
except Exception as exc:
finish(status="failed", message=str(exc), skill=skill)
raise
finally:
_cached_job_id = saved_job_id
_journal_path = saved_journal_path
_meta_written = saved_meta
if _journal_file is not None and _journal_file is not saved_file:
try:
_journal_file.close()
except Exception:
pass
_journal_file = saved_file
if saved_env_job is None:
os.environ.pop("JIANGCHANG_JOB_ID", None)
else:
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
if saved_env_mode is None:
os.environ.pop("JIANGCHANG_RUN_MODE", None)
else:
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
def rpa_step(name: str | None = None) -> Callable[[F], F]:
"""Decorator for sync/async RPA steps: emit enter/success/warn on failure."""
def decorator(fn: F) -> F:
step_name = name or fn.__name__
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
emit(f"{step_name}")
try:
result = await fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(f"{step_name}: {exc}", type="warn")
raise
return async_wrapper # type: ignore[return-value]
@functools.wraps(fn)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
emit(f"{step_name}")
try:
result = fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(f"{step_name}: {exc}", type="warn")
raise
return sync_wrapper # type: ignore[return-value]
return decorator
def _reset_for_tests() -> None:
"""Clear module-level journal state (tests only)."""
global _cached_job_id, _journal_path, _meta_written, _journal_file
if _journal_file is not None:
try:
_journal_file.close()
except Exception:
pass
_cached_job_id = None
_journal_path = None
_meta_written = False
_journal_file = None

View File

@@ -69,6 +69,69 @@ def reset_cache() -> None:
_example_defaults_cache = None _example_defaults_cache = None
def get_env_file_path() -> str | None:
return _env_file_path
def get_example_path() -> str | None:
return _example_path
def _iter_env_assignments(path: str) -> list[tuple[str, str]]:
"""按文件顺序返回 (key, 完整赋值行)。"""
result: list[tuple[str, str]] = []
if not path or not os.path.isfile(path):
return result
with open(path, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.rstrip("\n\r")
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, _ = stripped.partition("=")
key = key.strip()
if key:
result.append((key, stripped))
return result
def merge_missing_env_keys(
example_path: str,
dest_path: str,
*,
comment_skill: str | None = None,
) -> list[str]:
"""将 example 中有而用户 .env 没有的 key 追加到 dest 末尾,不修改已有项。"""
if not example_path or not os.path.isfile(example_path):
return []
if not dest_path or not os.path.isfile(dest_path):
return []
dest_keys = set(_parse_env_file(dest_path).keys())
to_append: list[tuple[str, str]] = []
for key, assignment in _iter_env_assignments(example_path):
if key not in dest_keys:
to_append.append((key, assignment))
if not to_append:
return []
header = (
f"# Added by {comment_skill} from .env.example"
if comment_skill
else "# Added from .env.example"
)
with open(dest_path, "a", encoding="utf-8") as f:
f.write("\n\n" + header + "\n")
for _, assignment in to_append:
f.write(assignment + "\n")
reset_cache()
return [key for key, _ in to_append]
def ensure_env_file(skill_slug: str, example_path: str) -> str: def ensure_env_file(skill_slug: str, example_path: str) -> str:
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env已存在不覆盖返回路径。""" """首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env已存在不覆盖返回路径。"""
global _skill_slug, _example_path, _env_file_path global _skill_slug, _example_path, _env_file_path

View File

@@ -0,0 +1,805 @@
"""共享媒体资源解析:背景音乐、字体、水印与 ffmpeg 工具。"""
from __future__ import annotations
import fnmatch
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Mapping, Optional
MEDIA_ASSETS_BUNDLE_URL = (
"https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip"
)
MEDIA_ASSETS_ZIP_URL = MEDIA_ASSETS_BUNDLE_URL
MEDIA_ASSETS_GIT_URL = "https://git.jc2009.com/client-commons/media-assets"
_WIN_DEFAULT_DATA_ROOT = Path(r"D:\jiangchang-data")
_NON_WIN_DEFAULT_DATA_ROOT = Path.home() / ".jiangchang"
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
_GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
_MIN_AUDIO_BYTES = 1024
def is_git_lfs_pointer(path: str | Path) -> bool:
try:
head = Path(path).read_bytes()[:256]
except OSError:
return False
return head.startswith(_GIT_LFS_POINTER_PREFIX)
_is_git_lfs_pointer = is_git_lfs_pointer
def _enumerate_audio_like(music_dir: Path) -> list[Path]:
return [
path
for path in music_dir.rglob("*")
if path.is_file() and path.suffix.lower() in _AUDIO_EXTENSIONS
]
def _music_content_issue(music_dir: Optional[Path]) -> Optional[str]:
if music_dir is None or not music_dir.is_dir():
return "music_dir_missing"
files = _enumerate_audio_like(music_dir)
if not files:
return "background_music_dir_empty"
if all(_is_git_lfs_pointer(path) for path in files):
return "background_music_lfs_pointer_only"
if not any(_is_usable_audio_file(path) for path in files):
return "background_music_invalid"
return None
def _music_is_ready(music_dir: Optional[Path]) -> bool:
return _music_content_issue(music_dir) is None
def _needs_music_content_repair(status: MediaAssetsStatus) -> bool:
if status.music_dir is None:
return "music_dir_missing" in status.warnings
issue = _music_content_issue(status.music_dir)
return issue in (
"background_music_dir_empty",
"background_music_lfs_pointer_only",
"background_music_invalid",
)
def is_usable_audio_file(path: str | Path, *, min_bytes: int = _MIN_AUDIO_BYTES) -> bool:
p = Path(path)
if not p.is_file():
return False
if p.suffix.lower() not in _AUDIO_EXTENSIONS:
return False
if is_git_lfs_pointer(p):
return False
try:
if p.stat().st_size < min_bytes:
return False
except OSError:
return False
return True
_is_usable_audio_file = is_usable_audio_file
@dataclass
class MediaAssetsStatus:
root: Path
exists: bool
ready: bool
source: str
warnings: list[str] = field(default_factory=list)
ffmpeg_path: Optional[Path] = None
ffprobe_path: Optional[Path] = None
music_dir: Optional[Path] = None
music_ready: bool = False
fonts_dir: Optional[Path] = None
watermark_dir: Optional[Path] = None
def _env_dict(env: dict[str, str] | None) -> dict[str, str]:
return env if env is not None else dict(os.environ)
def _media_assets_bundle_url(env: dict[str, str] | None = None) -> str:
env_map = _env_dict(env)
override = (env_map.get("MEDIA_ASSETS_BUNDLE_URL") or "").strip()
return override or MEDIA_ASSETS_BUNDLE_URL
def _resolve_jiangchang_data_root(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Path:
env_map = _env_dict(env)
from_env = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
if from_env:
return Path(from_env)
if data_root is not None:
return Path(data_root)
if sys.platform == "win32":
return _WIN_DEFAULT_DATA_ROOT
return _NON_WIN_DEFAULT_DATA_ROOT
def resolve_media_assets_root(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Path:
env_map = _env_dict(env)
media_root = (env_map.get("MEDIA_ASSETS_ROOT") or "").strip()
if media_root:
return Path(media_root)
data = _resolve_jiangchang_data_root(data_root, env)
return data / "shared" / "media-assets"
def _platform_bin_key() -> str:
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
if machine in {"amd64", "x86_64"}:
return "win32-x64"
if machine in {"arm64", "aarch64"}:
return "win32-arm64"
elif system == "darwin":
if machine in {"arm64", "aarch64"}:
return "darwin-arm64"
if machine in {"x86_64", "amd64"}:
return "darwin-x64"
elif system == "linux":
if machine in {"x86_64", "amd64"}:
return "linux-x64"
if machine in {"arm64", "aarch64"}:
return "linux-arm64"
return f"{system}-{machine}"
def _tool_filename(name: str) -> str:
if platform.system().lower() == "windows":
return f"{name}.exe"
return name
def _platform_tool_paths(root: Path) -> tuple[Path, Path]:
bin_key = _platform_bin_key()
bin_dir = root / "bin" / bin_key
return (
bin_dir / _tool_filename("ffmpeg"),
bin_dir / _tool_filename("ffprobe"),
)
def _read_manifest(root: Path) -> dict | None:
manifest_path = root / "manifest.json"
if not manifest_path.is_file():
return None
try:
return json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _ffmpeg_platform_spec(manifest: dict) -> dict | None:
tools = manifest.get("tools")
if not isinstance(tools, dict):
return None
ffmpeg = tools.get("ffmpeg")
if not isinstance(ffmpeg, dict):
return None
spec = ffmpeg.get(_platform_bin_key())
if isinstance(spec, dict):
return spec
return None
def _manifest_tool_urls(spec: dict) -> list[tuple[str, str]]:
urls: list[tuple[str, str]] = []
raw_urls = spec.get("urls")
if isinstance(raw_urls, list):
for item in raw_urls:
if isinstance(item, dict):
name = str(item.get("name") or "source")
url = str(item.get("url") or "").strip()
if url:
urls.append((name, url))
legacy = str(spec.get("url") or "").strip()
if legacy:
urls.append(("legacy", legacy))
return urls
def _match_archive_member(member_name: str, pattern: str) -> bool:
normalized = member_name.replace("\\", "/").lstrip("/")
return fnmatch.fnmatch(normalized, pattern)
def _extract_tool_members(
zip_path: Path,
archive_paths: dict[str, str],
) -> dict[str, bytes]:
found: dict[str, bytes] = {}
with zipfile.ZipFile(zip_path, "r") as zf:
for tool_name, pattern in archive_paths.items():
if not isinstance(pattern, str) or not pattern:
continue
for info in zf.infolist():
if info.is_dir():
continue
if _match_archive_member(info.filename, pattern):
found[tool_name] = zf.read(info)
break
return found
def _try_download_ffmpeg_tools(root: Path, warnings: list[str]) -> bool:
manifest = _read_manifest(root)
if manifest is None:
warnings.append("ffmpeg_manifest_missing")
return False
spec = _ffmpeg_platform_spec(manifest)
if spec is None:
warnings.append("ffmpeg_manifest_missing")
return False
archive_paths = spec.get("archive_paths")
if not isinstance(archive_paths, dict):
warnings.append("ffmpeg_manifest_missing")
return False
urls = _manifest_tool_urls(spec)
if not urls:
warnings.append("ffmpeg_tool_download_failed")
return False
ffmpeg_path, ffprobe_path = _platform_tool_paths(root)
bin_dir = ffmpeg_path.parent
download_root = _download_dir(_shared_parent(root)) / "ffmpeg-tools"
download_root.mkdir(parents=True, exist_ok=True)
for source_name, url in urls:
zip_path = download_root / f"{source_name}.zip"
try:
if zip_path.exists():
zip_path.unlink()
_download_zip(url, zip_path)
members = _extract_tool_members(zip_path, archive_paths)
ffmpeg_bytes = members.get("ffmpeg")
ffprobe_bytes = members.get("ffprobe")
if not ffmpeg_bytes or not ffprobe_bytes:
continue
bin_dir.mkdir(parents=True, exist_ok=True)
ffmpeg_path.write_bytes(ffmpeg_bytes)
ffprobe_path.write_bytes(ffprobe_bytes)
shutil.rmtree(download_root, ignore_errors=True)
return True
except urllib.error.URLError:
continue
except (zipfile.BadZipFile, OSError, KeyError):
continue
except Exception:
continue
warnings.append("ffmpeg_tool_download_failed")
return False
def _maybe_ensure_ffmpeg_tools(root: Path, status: MediaAssetsStatus) -> MediaAssetsStatus:
if status.ready:
return status
if not status.exists:
return status
if "ffmpeg_missing" not in status.warnings and "ffprobe_missing" not in status.warnings:
return status
warnings = list(status.warnings)
if _try_download_ffmpeg_tools(root, warnings):
refreshed = _inspect_media_assets(root, source=status.source)
for warning in warnings:
if warning not in refreshed.warnings:
refreshed.warnings.append(warning)
return refreshed
refreshed = _inspect_media_assets(root, source=status.source)
for warning in warnings:
if warning not in refreshed.warnings:
refreshed.warnings.append(warning)
return refreshed
def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
warnings: list[str] = []
exists = root.exists()
music_dir = root / "music"
fonts_dir = root / "fonts"
watermark_dir = root / "watermark"
ffmpeg_path, ffprobe_path = _platform_tool_paths(root)
music_exists = music_dir.is_dir()
fonts_exists = fonts_dir.is_dir()
watermark_exists = watermark_dir.is_dir()
ffmpeg_exists = ffmpeg_path.is_file()
ffprobe_exists = ffprobe_path.is_file()
if not music_exists:
warnings.append("music_dir_missing")
if not fonts_exists:
warnings.append("fonts_dir_missing")
if not watermark_exists:
warnings.append("watermark_dir_missing")
if not ffmpeg_exists:
warnings.append("ffmpeg_missing")
if not ffprobe_exists:
warnings.append("ffprobe_missing")
music_ready = False
if music_exists:
music_issue = _music_content_issue(music_dir)
if music_issue and music_issue != "music_dir_missing":
warnings.append(music_issue)
music_ready = music_issue is None
ready = (
exists
and music_exists
and fonts_exists
and watermark_exists
and ffmpeg_exists
and ffprobe_exists
and music_ready
)
return MediaAssetsStatus(
root=root,
exists=exists,
ready=ready,
source=source,
warnings=warnings,
ffmpeg_path=ffmpeg_path if ffmpeg_exists else None,
ffprobe_path=ffprobe_path if ffprobe_exists else None,
music_dir=music_dir if music_exists else None,
music_ready=music_ready,
fonts_dir=fonts_dir if fonts_exists else None,
watermark_dir=watermark_dir if watermark_exists else None,
)
def _shared_parent(root: Path) -> Path:
return root.parent
def _download_dir(shared: Path) -> Path:
return shared / ".media-assets-download"
def _find_media_assets_root_in_tree(extracted: Path) -> Path | None:
if not extracted.is_dir():
return None
def _looks_like_assets(path: Path) -> bool:
if (path / "music").is_dir():
return True
if (path / "fonts").is_dir():
return True
if (path / "watermark").is_dir():
return True
if (path / "README.md").is_file():
return True
return False
if _looks_like_assets(extracted):
return extracted
children = [p for p in extracted.iterdir() if p.is_dir()]
for child in children:
if _looks_like_assets(child):
return child
for path in extracted.rglob("*"):
if path.is_dir() and _looks_like_assets(path):
return path
return None
def _download_zip(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(url, timeout=120) as response:
dest.write_bytes(response.read())
def _extract_zip(zip_path: Path, extract_to: Path) -> None:
extract_to.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(extract_to)
def _find_git_executable() -> Path | None:
git = shutil.which("git")
return Path(git) if git else None
def _git_clone(url: str, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "clone", url, str(target)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _git_pull(target: Path) -> None:
subprocess.run(
["git", "pull"],
cwd=str(target),
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _is_git_repo(path: Path) -> bool:
return (path / ".git").exists()
def _install_media_assets(source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists():
shutil.move(str(source), str(target))
return
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup = target.parent / f"media-assets.backup-{timestamp}"
shutil.move(str(target), str(backup))
try:
shutil.move(str(source), str(target))
except Exception:
if backup.exists() and not target.exists():
shutil.move(str(backup), str(target))
raise
else:
shutil.rmtree(backup, ignore_errors=True)
def _cleanup_download_artifacts(download_root: Path) -> None:
shutil.rmtree(download_root, ignore_errors=True)
def _try_download_via_zip(
shared: Path,
target: Path,
warnings: list[str],
*,
env: dict[str, str] | None = None,
url: str | None = None,
) -> bool:
download_root = _download_dir(shared)
download_root.mkdir(parents=True, exist_ok=True)
zip_path = download_root / "media-assets.zip"
extracted = download_root / "extracted"
staging = download_root / "staging"
bundle_url = url or _media_assets_bundle_url(env)
try:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
if extracted.exists():
shutil.rmtree(extracted, ignore_errors=True)
_download_zip(bundle_url, zip_path)
_extract_zip(zip_path, extracted)
found = _find_media_assets_root_in_tree(extracted)
if found is None:
warnings.append("media_assets_zip_invalid_layout")
return False
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
shutil.copytree(found, staging)
_install_media_assets(staging, target)
_cleanup_download_artifacts(download_root)
return True
except urllib.error.URLError:
warnings.append("media_assets_zip_download_failed")
return False
except zipfile.BadZipFile:
warnings.append("media_assets_zip_extract_failed")
return False
except OSError:
warnings.append("media_assets_zip_extract_failed")
return False
except Exception:
warnings.append("media_assets_zip_extract_failed")
return False
def _try_download_via_git(
target: Path,
warnings: list[str],
*,
clone: bool,
) -> bool:
git_exe = _find_git_executable()
if git_exe is None:
warnings.append("git_not_available")
return False
try:
if clone:
if target.exists():
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
partial = target.parent / f"media-assets.git-staging-{timestamp}"
if partial.exists():
shutil.rmtree(partial, ignore_errors=True)
_git_clone(MEDIA_ASSETS_GIT_URL, partial)
_install_media_assets(partial, target)
else:
_git_clone(MEDIA_ASSETS_GIT_URL, target)
else:
_git_pull(target)
return True
except (subprocess.CalledProcessError, OSError):
if clone:
warnings.append("media_assets_git_clone_failed")
return False
def _media_assets_root_overridden(env: dict[str, str] | None) -> bool:
env_map = _env_dict(env)
return bool((env_map.get("MEDIA_ASSETS_ROOT") or "").strip())
def _needs_media_repo_content(status: MediaAssetsStatus) -> bool:
return any(
warning in status.warnings
for warning in (
"music_dir_missing",
"fonts_dir_missing",
"watermark_dir_missing",
"background_music_dir_empty",
"background_music_lfs_pointer_only",
"background_music_invalid",
)
)
def _maybe_repair_music_content(
root: Path,
status: MediaAssetsStatus,
*,
env: dict[str, str] | None = None,
) -> MediaAssetsStatus:
if not _needs_music_content_repair(status):
return status
if _media_assets_root_overridden(env):
return status
warnings: list[str] = []
if _is_git_repo(root) and root.exists():
if _try_download_via_git(root, warnings, clone=False):
refreshed = _inspect_media_assets(root, source="git")
for warning in warnings:
if warning not in refreshed.warnings:
refreshed.warnings.append(warning)
return _maybe_ensure_ffmpeg_tools(root, refreshed)
shared = _shared_parent(root)
if _try_download_via_zip(shared, root, warnings, env=env):
refreshed = _inspect_media_assets(root, source="zip")
for warning in warnings:
if warning not in refreshed.warnings:
refreshed.warnings.append(warning)
return _maybe_ensure_ffmpeg_tools(root, refreshed)
refreshed = _inspect_media_assets(root, source=status.source)
for warning in warnings:
if warning not in refreshed.warnings:
refreshed.warnings.append(warning)
return refreshed
def ensure_media_assets(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
*,
update: bool = False,
) -> MediaAssetsStatus:
env_map = _env_dict(env)
root = resolve_media_assets_root(data_root, env_map)
overridden = _media_assets_root_overridden(env_map)
status = _inspect_media_assets(root, source="local")
if overridden:
return _maybe_ensure_ffmpeg_tools(root, status)
if status.exists and not status.ready and not update and not _needs_media_repo_content(status):
return _maybe_ensure_ffmpeg_tools(root, status)
if status.ready and not update:
return status
if status.ready and update:
if _is_git_repo(root):
warnings: list[str] = []
if _try_download_via_git(root, warnings, clone=False):
return _maybe_ensure_ffmpeg_tools(
root,
_inspect_media_assets(root, source="git"),
)
status = _inspect_media_assets(root, source="local")
status.warnings.extend(warnings)
return _maybe_ensure_ffmpeg_tools(root, status)
shared = _shared_parent(root)
shared.mkdir(parents=True, exist_ok=True)
warnings: list[str] = []
if _try_download_via_zip(shared, root, warnings, env=env_map):
return _maybe_ensure_ffmpeg_tools(
root,
_maybe_repair_music_content(
root,
_inspect_media_assets(root, source="zip"),
env=env_map,
),
)
clone_needed = not root.exists() or not status.ready or update
if _try_download_via_git(root, warnings, clone=clone_needed):
return _maybe_ensure_ffmpeg_tools(
root,
_maybe_repair_music_content(
root,
_inspect_media_assets(
root,
source="git" if _is_git_repo(root) else "local",
),
env=env_map,
),
)
final = _inspect_media_assets(root, source="local")
for warning in warnings:
if warning not in final.warnings:
final.warnings.append(warning)
repaired = _maybe_repair_music_content(root, final, env=env_map)
for warning in warnings:
if warning not in repaired.warnings:
repaired.warnings.append(warning)
return _maybe_ensure_ffmpeg_tools(root, repaired)
def probe_media_assets(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> MediaAssetsStatus:
"""只检查本地 media-assets 目录,不触发下载。"""
env_map = _env_dict(env)
root = resolve_media_assets_root(data_root, env_map)
return _inspect_media_assets(root, source="local")
def probe_ffmpeg(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[Path]:
status = probe_media_assets(data_root, env)
return status.ffmpeg_path
def resolve_ffmpeg(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[Path]:
status = ensure_media_assets(data_root, env)
return status.ffmpeg_path
def resolve_ffprobe(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[Path]:
status = ensure_media_assets(data_root, env)
return status.ffprobe_path
def resolve_music_dir(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[Path]:
status = ensure_media_assets(data_root, env)
return status.music_dir
def probe_background_music(
root: str | Path | None = None,
*,
env: Mapping[str, str] | None = None,
) -> dict[str, Any]:
"""只读探测背景音乐目录,不触发 media-assets 下载。"""
env_map = _env_dict(dict(env) if env is not None else None)
if root is not None:
music_dir = Path(root) / "music"
if not music_dir.is_dir():
music_dir = Path(root) if Path(root).is_dir() else None
else:
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
assets_root = resolve_media_assets_root(data_root or None, env_map)
status = _inspect_media_assets(assets_root, source="local")
music_dir = status.music_dir
music_root = str(music_dir) if music_dir is not None else None
if music_dir is None or not music_dir.is_dir():
return {
"music_root": music_root,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": "music_dir_missing",
}
mp3_files = [path for path in music_dir.rglob("*.mp3") if path.is_file()]
usable = [path for path in music_dir.rglob("*") if is_usable_audio_file(path)]
issue = _music_content_issue(music_dir)
sample = str(sorted(usable, key=lambda p: str(p).lower())[0]) if usable else None
return {
"music_root": music_root,
"mp3_count": len(mp3_files),
"usable_count": len(usable),
"sample_path": sample,
"issue": issue,
}
def background_music_issue(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[str]:
"""若 music 目录存在但无可用音频,返回标准 warning code。"""
if pick_background_music(data_root, env) is not None:
return None
status = ensure_media_assets(data_root, env)
return _music_content_issue(status.music_dir)
def pick_background_music(
data_root: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Optional[Path]:
status = ensure_media_assets(data_root, env)
music_dir = status.music_dir
if music_dir is None or not music_dir.is_dir():
return None
candidates = [
path
for path in music_dir.rglob("*")
if _is_usable_audio_file(path)
]
if not candidates:
return None
candidates.sort(key=lambda p: str(p).lower())
return candidates[0]

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import os import os
from datetime import datetime from datetime import datetime
from .. import config
def _artifacts_enabled() -> bool: def _artifacts_enabled() -> bool:
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower() return config.get_bool("OPENCLAW_ARTIFACTS_ON_FAILURE", default=True)
return v not in ("0", "false", "no", "off")
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str: def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:

View File

@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .. import config
from ..runtime_env import find_chrome_executable from ..runtime_env import find_chrome_executable
from .stealth import ( from .stealth import (
STEALTH_INIT_SCRIPT, STEALTH_INIT_SCRIPT,
@@ -31,8 +31,7 @@ async def launch_persistent_browser(
录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。 录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。
""" """
if headless is None: if headless is None:
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
headless = v in ("1", "true", "yes", "on")
chrome = executable_path or find_chrome_executable() chrome = executable_path or find_chrome_executable()
if not chrome: if not chrome:

View File

@@ -5,7 +5,7 @@
from __future__ import annotations from __future__ import annotations
import os from .. import config
STEALTH_INIT_SCRIPT = """ STEALTH_INIT_SCRIPT = """
(() => { (() => {
@@ -48,8 +48,7 @@ STEALTH_INIT_SCRIPT = """
def stealth_enabled() -> bool: def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower() return config.get_bool("OPENCLAW_PLAYWRIGHT_STEALTH", default=True)
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts( def persistent_context_launch_parts(

View File

@@ -1,11 +1,14 @@
"""RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。""" """RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 旁白 + 背景音乐 → 最终 MP4。
背景音乐默认循环铺满录屏时长并在结尾淡出;旁白使用 Windows 本地 SAPI 合成,
失败时静默降级不影响视频生成。Skill 无需额外配置。
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os import os
import random import re
import shutil
import subprocess import subprocess
import sys import sys
import time import time
@@ -15,6 +18,13 @@ from pathlib import Path
from typing import Any, Dict, IO, List, Optional from typing import Any, Dict, IO, List, Optional
from .. import config from .. import config
from ..media_assets import (
background_music_issue,
ensure_media_assets,
pick_background_music,
resolve_ffmpeg,
resolve_ffprobe,
)
_JIANGCHANG_SUBTITLE_STYLE = ( _JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14," "FontName=Arial,FontSize=14,"
@@ -22,26 +32,140 @@ _JIANGCHANG_SUBTITLE_STYLE = (
"Outline=2,Shadow=1,Alignment=2" "Outline=2,Shadow=1,Alignment=2"
) )
_DEFAULT_MUSIC_ROOT = os.environ.get( _CAPTURE_FRAMERATE = 15
"MEDIA_ASSETS_ROOT",
r"D:\OpenClaw\client-commons\media-assets", # 背景音乐循环 + 结尾淡出(默认策略,非 env 配置)
_MUSIC_VOLUME = 0.12
_VOICE_VOLUME = 1.0
_MUSIC_FADE_OUT_SECONDS = 3.0
_MIN_MUSIC_FADE_OUT_SECONDS = 1.0
_SHORT_VIDEO_FADE_RATIO = 0.25
_VOICEOVER_MAX_CHARS = 60
_VOICEOVER_DEDUP_SECONDS = 10.0
_VOICEOVER_MIN_GAP_SECONDS = 2.0
_INTRO_BUFFER_SECONDS = 5.0
_INTRO_STABILIZE_SECONDS = 1.0
_OUTRO_BUFFER_SECONDS = 5.0
_VOICEOVER_SKIP_SUBSTRINGS = (
"等待 1-5s",
"跳过重复",
"写入联系人库",
"探针模式",
"异常",
"失败",
) )
_CAPTURE_FRAMERATE = 15 _VOICEOVER_IMPORTANT_SUBSTRINGS = (
"开始",
"打开",
"启动",
"定位",
"输入",
"点击",
"搜索",
"等待搜索结果",
"检查登录状态",
"处理验证码",
"等待人工验证",
"解析",
"提取",
"采集完成",
"部分完成",
"完成",
)
# 明显技术日志:时间戳前缀、纯数字进度等
_TECH_LOG_RE = re.compile(
r"^\d{4}-\d{2}-\d{2}|^\[\w+\]|^DEBUG:|^INFO:|^WARN:|^ERROR:"
)
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)
def _resolve_music_file() -> Optional[Path]: def _resolve_ffmpeg_exe() -> Optional[Path]:
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music" ffmpeg = resolve_ffmpeg()
if not music_dir.is_dir(): if ffmpeg is None:
return None return None
files = list(music_dir.rglob("*.mp3")) path = Path(ffmpeg)
if not files: return path if path.is_file() else None
def _resolve_ffprobe_exe() -> Optional[Path]:
"""定位 ffprobe优先 media_assets bundle否则尝试 ffmpeg 同目录。"""
ffprobe = resolve_ffprobe()
if ffprobe is not None:
path = Path(ffprobe)
if path.is_file():
return path
ffmpeg = _resolve_ffmpeg_exe()
if ffmpeg is not None:
sibling = ffmpeg.parent / ("ffprobe.exe" if sys.platform == "win32" else "ffprobe")
if sibling.is_file():
return sibling
return None
def _probe_media_duration_seconds(
ffprobe_exe: Optional[Path], media_path: Path
) -> Optional[float]:
if ffprobe_exe is None or not media_path.is_file():
return None return None
return random.choice(files) try:
result = subprocess.run(
[
str(ffprobe_exe),
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nk=1:nw=1",
str(media_path),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
if result.returncode != 0:
return None
raw = (result.stdout or "").strip()
if not raw:
return None
value = float(raw)
return value if value > 0 else None
except Exception:
return None
def _music_fade_params(
duration_seconds: Optional[float],
) -> tuple[Optional[float], Optional[float]]:
if duration_seconds is None or duration_seconds <= 0:
return (None, None)
fade = min(
_MUSIC_FADE_OUT_SECONDS,
max(_MIN_MUSIC_FADE_OUT_SECONDS, duration_seconds * _SHORT_VIDEO_FADE_RATIO),
)
fade_start = max(0.0, duration_seconds - fade)
return (fade_start, fade)
def _resolve_music_file(warnings: List[str]) -> Optional[Path]:
music = pick_background_music()
if music is not None:
return music
issue = background_music_issue()
if issue:
warnings.append(issue)
return None
@dataclass @dataclass
@@ -51,6 +175,183 @@ class _StepEntry:
duration: float = 4.0 duration: float = 4.0
@dataclass
class _VoiceClip:
path: Path
delay_ms: int
def _voiceover_text_for_step(text: str) -> Optional[str]:
"""过滤适合朗读的步骤文本;噪音/技术日志返回 None。"""
cleaned = (text or "").strip()
if not cleaned:
return None
if _TECH_LOG_RE.search(cleaned):
return None
for skip in _VOICEOVER_SKIP_SUBSTRINGS:
if skip in cleaned:
return None
if len(cleaned) > _VOICEOVER_MAX_CHARS:
cleaned = cleaned[:_VOICEOVER_MAX_CHARS]
return cleaned
def _is_important_voiceover(text: str) -> bool:
for important in _VOICEOVER_IMPORTANT_SUBSTRINGS:
if important in text:
return True
return False
def _select_voiceover_clips(
entries: List[_StepEntry],
) -> List[tuple[_StepEntry, str]]:
"""按时间排序,去重并保证旁白间隔;关键动作可绕过最小间隔。"""
result: List[tuple[_StepEntry, str]] = []
last_text_at: Dict[str, float] = {}
last_spoken_at = -_VOICEOVER_MIN_GAP_SECONDS
for entry in sorted(entries, key=lambda e: e.start):
spoken = _voiceover_text_for_step(entry.text)
if not spoken:
continue
prev = last_text_at.get(spoken)
if prev is not None and entry.start - prev < _VOICEOVER_DEDUP_SECONDS:
continue
if (
not _is_important_voiceover(spoken)
and entry.start - last_spoken_at < _VOICEOVER_MIN_GAP_SECONDS
):
continue
last_text_at[spoken] = entry.start
last_spoken_at = entry.start
result.append((entry, spoken))
return result
def _tts_available() -> bool:
return sys.platform == "win32"
def _sapi_synthesize_wav(text: str, output_wav: Path) -> bool:
if not _tts_available():
return False
output_wav.parent.mkdir(parents=True, exist_ok=True)
out_str = str(output_wav.resolve()).replace("'", "''")
text_esc = text.replace("'", "''")
ps_script = (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$s.SetOutputToWaveFile('{out_str}'); "
f"$s.Speak('{text_esc}'); "
"$s.Dispose()"
)
try:
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
return (
result.returncode == 0
and output_wav.is_file()
and output_wav.stat().st_size > 0
)
except Exception:
return False
def _mix_voice_clips(
ffmpeg_exe: Path, clips: List[_VoiceClip], output_wav: Path
) -> bool:
if not clips:
return False
output_wav.parent.mkdir(parents=True, exist_ok=True)
filter_parts: List[str] = []
mix_labels: List[str] = []
cmd_inputs: List[str] = []
for i, clip in enumerate(clips):
cmd_inputs.extend(["-i", str(clip.path)])
label = f"v{i}"
filter_parts.append(
f"[{i}:a]adelay={clip.delay_ms}|{clip.delay_ms},"
f"volume={_VOICE_VOLUME}[{label}]"
)
mix_labels.append(f"[{label}]")
if len(clips) == 1:
filter_complex = filter_parts[0].replace(f"[v0]", "[out]")
# single clip: relabel output
filter_complex = (
f"[0:a]adelay={clips[0].delay_ms}|{clips[0].delay_ms},"
f"volume={_VOICE_VOLUME}[out]"
)
else:
filter_complex = (
";".join(filter_parts)
+ ";"
+ "".join(mix_labels)
+ f"amix=inputs={len(clips)}:duration=longest:dropout_transition=0:normalize=0[out]"
)
cmd_tail = [
"-y",
*cmd_inputs,
"-filter_complex",
filter_complex,
"-map",
"[out]",
str(output_wav),
]
ok, _ = _run_ffmpeg(ffmpeg_exe, cmd_tail)
return ok and output_wav.is_file()
def _synthesize_step_voiceover(
entries: List[_StepEntry],
output_wav: Path,
warnings: List[str],
*,
ffmpeg_exe: Optional[Path] = None,
) -> Optional[Path]:
"""本地 Windows SAPI 逐条合成旁白并按步骤 start 时间对齐;失败返回 None。"""
if not _tts_available():
warnings.append("tts_unsupported_platform")
return None
clips_to_speak = _select_voiceover_clips(entries)
if not clips_to_speak:
return None
voice_dir = output_wav.parent
voice_dir.mkdir(parents=True, exist_ok=True)
temp_clips: List[_VoiceClip] = []
for i, (entry, spoken) in enumerate(clips_to_speak):
clip_path = voice_dir / f"voice_{i:03d}.wav"
if not _sapi_synthesize_wav(spoken, clip_path):
warnings.append("tts_synthesis_failed")
return None
delay_ms = max(0, int(entry.start * 1000))
temp_clips.append(_VoiceClip(path=clip_path, delay_ms=delay_ms))
if ffmpeg_exe is None:
ffmpeg_exe = _resolve_ffmpeg_exe()
if ffmpeg_exe is None:
warnings.append("tts_mix_ffmpeg_missing")
return None
if not _mix_voice_clips(ffmpeg_exe, temp_clips, output_wav):
warnings.append("tts_mix_failed")
return None
return output_wav
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None: def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
MIN_DURATION = 1.0 MIN_DURATION = 1.0
GAP = 0.1 GAP = 0.1
@@ -87,7 +388,12 @@ def _escape_srt_path(path: str) -> str:
return path.replace("\\", "/").replace(":", "\\:") return path.replace("\\", "/").replace(":", "\\:")
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]: def _ffmpeg_cmd(ffmpeg_exe: Path, *args: str) -> List[str]:
return [str(ffmpeg_exe), *args]
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: List[str]) -> tuple[bool, str]:
cmd = _ffmpeg_cmd(ffmpeg_exe, *cmd_tail)
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
@@ -104,36 +410,27 @@ def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
return True, "" return True, ""
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]: def _build_music_filter(
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。""" audio_input_idx: int,
if sys.platform != "win32": fade_start: Optional[float],
return None fade_duration: Optional[float],
return [ output_label: str = "music",
"ffmpeg", ) -> str:
"-y", filt = f"[{audio_input_idx}:a]volume={_MUSIC_VOLUME}"
"-f", if fade_start is not None and fade_duration is not None:
"gdigrab", filt += f",afade=t=out:st={fade_start}:d={fade_duration}"
"-framerate", return filt + f"[{output_label}]"
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
]
def _compose_capture_mp4( def _compose_capture_mp4(
ffmpeg_exe: Path,
capture_path: Path, capture_path: Path,
srt_path: Path, srt_path: Path,
output_path: Path, output_path: Path,
*, *,
music_file: Optional[Path] = None, music_file: Optional[Path] = None,
music_volume: float = 0.15, voiceover_file: Optional[Path] = None,
warnings: Optional[List[str]] = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
srt_esc = _escape_srt_path(str(srt_path.resolve())) srt_esc = _escape_srt_path(str(srt_path.resolve()))
@@ -142,42 +439,79 @@ def _compose_capture_mp4(
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'" f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
) )
if music_file and music_file.is_file(): has_music = music_file is not None and music_file.is_file()
filter_complex = ( has_voice = voiceover_file is not None and voiceover_file.is_file()
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]" fade_start: Optional[float] = None
fade_duration: Optional[float] = None
if has_music:
ffprobe = _resolve_ffprobe_exe()
duration = _probe_media_duration_seconds(ffprobe, capture_path)
fade_start, fade_duration = _music_fade_params(duration)
if fade_start is None and warnings is not None:
warnings.append("video_duration_probe_failed_for_music_fade")
if has_music or has_voice:
cmd_tail: List[str] = ["-y", "-i", str(capture_path)]
input_idx = 1
filter_parts: List[str] = [f"[0:v]{subtitle_filter}[vout]"]
audio_labels: List[str] = []
if has_music:
cmd_tail.extend(["-stream_loop", "-1", "-i", str(music_file)])
music_label = "aout" if not has_voice else "music"
filter_parts.append(
_build_music_filter(input_idx, fade_start, fade_duration, music_label)
)
if has_voice:
audio_labels.append(f"[{music_label}]")
input_idx += 1
if has_voice:
cmd_tail.extend(["-i", str(voiceover_file)])
if has_music:
filter_parts.append(
f"[{input_idx}:a]volume={_VOICE_VOLUME}[voice]"
)
audio_labels.append("[voice]")
else:
# voice-onlyapad 补静音,避免 -shortest 按旁白长度截短视频
filter_parts.append(
f"[{input_idx}:a]volume={_VOICE_VOLUME},apad[aout]"
)
input_idx += 1
if len(audio_labels) == 2:
filter_parts.append(
"".join(audio_labels) + "amix=inputs=2:normalize=0[aout]"
)
cmd_tail.extend(
[
"-filter_complex",
";".join(filter_parts),
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-pix_fmt",
"yuv420p",
str(output_path),
]
) )
cmd = [
"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",
str(output_path),
]
else: else:
cmd = [ cmd_tail = [
"ffmpeg",
"-y", "-y",
"-i", "-i",
str(capture_path), str(capture_path),
@@ -197,7 +531,30 @@ def _compose_capture_mp4(
"yuv420p", "yuv420p",
str(output_path), str(output_path),
] ]
return _run_ffmpeg(cmd) return _run_ffmpeg(ffmpeg_exe, cmd_tail)
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
if sys.platform != "win32":
return None
return _ffmpeg_cmd(
ffmpeg_exe,
"-y",
"-f",
"gdigrab",
"-framerate",
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
)
class RpaVideoSession: class RpaVideoSession:
@@ -210,11 +567,15 @@ class RpaVideoSession:
skill_data_dir: str, skill_data_dir: str,
batch_id: str, batch_id: str,
title: str = "", title: str = "",
closing_title: str = "",
emit_activity: bool = True,
) -> None: ) -> None:
self.skill_slug = skill_slug self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir) self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id self.batch_id = batch_id
self.title = title self.title = title
self.closing_title = closing_title
self.emit_activity = emit_activity
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
@@ -230,12 +591,17 @@ class RpaVideoSession:
self.skill_data_dir, "rpa-artifacts", batch_id self.skill_data_dir, "rpa-artifacts", batch_id
) )
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles") self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
self._voiceover_dir = os.path.join(self._artifact_root, "voiceover")
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._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._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._voiceover_path = os.path.join(self._voiceover_dir, "voiceover.wav")
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._music_path: Optional[str] = None
self._voiceover_output: Optional[str] = None
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_proc: Optional[subprocess.Popen] = None
@@ -244,6 +610,7 @@ class RpaVideoSession:
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._subtitles_dir, exist_ok=True) os.makedirs(self._subtitles_dir, exist_ok=True)
os.makedirs(self._voiceover_dir, exist_ok=True)
os.makedirs(self._logs_dir, exist_ok=True) os.makedirs(self._logs_dir, exist_ok=True)
os.makedirs(self._artifact_root, exist_ok=True) os.makedirs(self._artifact_root, exist_ok=True)
@@ -266,13 +633,25 @@ class RpaVideoSession:
self.add_step(text, duration=duration) self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None: def add_step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(相对录屏开始时间)。""" """记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream"""
cleaned = text.strip()
if self.emit_activity and cleaned:
try:
from .. import activity
activity.emit(
cleaned,
skill=self.skill_slug,
stage="rpa.video",
)
except Exception:
pass
if not self.enabled: if not self.enabled:
return return
if self._t0 is None: if self._t0 is None:
self._t0 = time.monotonic() self._t0 = time.monotonic()
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=cleaned, duration=duration))
def _close_ffmpeg_record_log(self) -> None: def _close_ffmpeg_record_log(self) -> None:
fh = self._ffmpeg_record_log_file fh = self._ffmpeg_record_log_file
@@ -287,11 +666,13 @@ class RpaVideoSession:
def _start_ffmpeg_capture(self) -> None: def _start_ffmpeg_capture(self) -> None:
if not self.enabled: if not self.enabled:
return return
if not shutil.which("ffmpeg"):
ffmpeg_exe = _resolve_ffmpeg_exe()
if ffmpeg_exe is None:
self.warnings.append("ffmpeg_not_found") self.warnings.append("ffmpeg_not_found")
return return
cmd = _build_desktop_record_cmd(self._capture_path) cmd = _build_desktop_record_cmd(ffmpeg_exe, self._capture_path)
if cmd is None: if cmd is None:
self._append_warning( self._append_warning(
"ffmpeg_record_start_failed", "ffmpeg_record_start_failed",
@@ -352,19 +733,80 @@ class RpaVideoSession:
self._close_ffmpeg_record_log() self._close_ffmpeg_record_log()
time.sleep(0.3) time.sleep(0.3)
async def _safe_add_step(self, text: str, *, duration: float = 4.0) -> None:
try:
self.add_step(text, duration=duration)
except Exception:
pass
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:
self.add_step(self.title, duration=3.0)
self._start_ffmpeg_capture() self._start_ffmpeg_capture()
await asyncio.sleep(_INTRO_STABILIZE_SECONDS)
if self.title:
await self._safe_add_step(self.title, duration=4.0)
intro_wait = _INTRO_BUFFER_SECONDS - _INTRO_STABILIZE_SECONDS
if intro_wait > 0:
await asyncio.sleep(intro_wait)
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:
if self.closing_title:
await self._safe_add_step(self.closing_title, duration=4.0)
await asyncio.sleep(_OUTRO_BUFFER_SECONDS)
self._stop_ffmpeg_capture() self._stop_ffmpeg_capture()
await self.finalize() await self.finalize()
def _try_compose_variants(
self,
ffmpeg_exe: Path,
capture: Path,
srt_file: Path,
output: Path,
*,
music: Optional[Path],
voiceover: Optional[Path],
use_srt: bool,
) -> tuple[bool, str]:
"""按 music/voice 组合尝试合成,失败时逐级降级。"""
srt = srt_file if use_srt else Path(self._srt_path)
variants: List[tuple[Optional[Path], Optional[Path]]] = [
(music, voiceover),
]
if music and voiceover:
variants.append((None, voiceover))
if music:
variants.append((music, None))
variants.append((None, None))
seen: set[tuple[Optional[str], Optional[str]]] = set()
last_err = ""
for m, v in variants:
key = (str(m) if m else None, str(v) if v else None)
if key in seen:
continue
seen.add(key)
ok, err = _compose_capture_mp4(
ffmpeg_exe,
capture,
srt,
output,
music_file=m,
voiceover_file=v,
warnings=self.warnings,
)
if ok:
return True, ""
last_err = err
if m is not None and v is not None:
self.warnings.append(f"ffmpeg_with_music_and_voice_failed: {err}")
elif m is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
return False, last_err
async def finalize(self) -> None: async def finalize(self) -> None:
"""停止录制后合成最终 MP4失败只记 warning不抛异常。""" """停止录制后合成最终 MP4失败只记 warning不抛异常。"""
if not self.enabled: if not self.enabled:
@@ -391,31 +833,51 @@ class RpaVideoSession:
except Exception as exc: except Exception as exc:
self.warnings.append(f"subtitle_write_failed: {exc}") self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file() music = _resolve_music_file(self.warnings)
if music is None: if music is not None:
self._music_path = str(music.resolve())
elif (
"background_music_lfs_pointer_only" not in self.warnings
and "background_music_invalid" not in self.warnings
):
self.warnings.append("background_music_missing") self.warnings.append("background_music_missing")
ffmpeg_exe = _resolve_ffmpeg_exe()
if ffmpeg_exe is None:
self.warnings.append("ffmpeg_not_found")
self.artifacts = self.summary()
return
voiceover: Optional[Path] = None
try:
vo = _synthesize_step_voiceover(
entries,
Path(self._voiceover_path),
self.warnings,
ffmpeg_exe=ffmpeg_exe,
)
if vo is not None:
voiceover = vo
self._voiceover_output = str(vo.resolve())
except Exception as exc:
self.warnings.append(f"tts_unexpected_error: {exc}")
srt_file = Path(self._srt_path) srt_file = Path(self._srt_path)
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0 use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
ok, err = _compose_capture_mp4( ok, err = self._try_compose_variants(
ffmpeg_exe,
capture, capture,
srt_file if use_srt else Path(self._srt_path), srt_file,
Path(self._planned_output), Path(self._planned_output),
music_file=music, music=music,
voiceover=voiceover,
use_srt=use_srt,
) )
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
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: if not ok and not use_srt:
ok, err = _run_ffmpeg( ok, err = _run_ffmpeg(
ffmpeg_exe,
[ [
"ffmpeg",
"-y", "-y",
"-i", "-i",
str(capture), str(capture),
@@ -428,7 +890,7 @@ class RpaVideoSession:
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
str(self._planned_output), str(self._planned_output),
] ],
) )
if not ok: if not ok:
self._append_warning("ffmpeg_compose_failed", err) self._append_warning("ffmpeg_compose_failed", err)
@@ -447,12 +909,28 @@ class RpaVideoSession:
if self.enabled and self._ffmpeg_record_log_path if self.enabled and self._ffmpeg_record_log_path
else None else None
) )
audio_warnings = [
w
for w in self.warnings
if w.startswith(
(
"tts_",
"video_duration_probe_failed",
"ffmpeg_with_music",
"background_music_",
)
)
or w == "background_music_missing"
]
return { return {
"enabled": self.enabled, "enabled": self.enabled,
"path": self.output_video_path, "path": self.output_video_path,
"capture_path": self.capture_path, "capture_path": self.capture_path,
"record_log_path": record_log, "record_log_path": record_log,
"warnings": list(self.warnings), "warnings": list(self.warnings),
"voiceover_path": self._voiceover_output,
"music_path": self._music_path,
"audio_warnings": audio_warnings,
} }
def to_artifact_dict(self) -> Dict[str, Any]: def to_artifact_dict(self) -> Dict[str, Any]:

View File

@@ -0,0 +1,338 @@
"""共享 Runtime 诊断health 输出与 media-assets / ffmpeg 探测。"""
from __future__ import annotations
import importlib.metadata
import logging
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
import jiangchang_skill_core
from . import config
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
from .runtime_env import platform_default_data_root
_SHARED_CORE_HINT = (
"jiangchang_skill_core is loaded from the skill tree; expected the shared "
"jiangchang-platform-kit installed in the host Python runtime."
)
@dataclass(frozen=True)
class RuntimeIssue:
code: str
message: str
severity: str = "warning"
@dataclass(frozen=True)
class RuntimeDiagnostics:
skill_slug: str
python_executable: str
platform_kit_version: str | None
platform_kit_min_version: str | None
platform_kit_version_ok: bool | None
jiangchang_skill_core_file: str | None
jiangchang_data_root: str | None
resolved_data_root: str | None
media_assets_root: str | None
ffmpeg_available: bool
ffmpeg_path: str | None
background_music_mp3_count: int
background_music_usable_count: int
background_music_issue: str | None
background_music_sample_path: str | None
record_video_enabled: bool
issues: tuple[RuntimeIssue, ...]
@property
def has_fatal_issues(self) -> bool:
return any(issue.severity == "error" for issue in self.issues)
def issue_codes(self) -> list[str]:
return [issue.code for issue in self.issues]
def _env_dict(env: Mapping[str, str] | None) -> dict[str, str]:
return dict(env) if env is not None else dict(os.environ)
def _resolve_data_root(env: Mapping[str, str]) -> str:
root = (env.get("JIANGCHANG_DATA_ROOT") or "").strip()
if root:
return root
return platform_default_data_root()
def _parse_version(version: str) -> tuple[int, int, int, int]:
"""Parse major.minor.patch[.postN] into (major, minor, patch, post)."""
text = version.strip()
post = 0
if ".post" in text:
base, _, post_part = text.partition(".post")
post_digits = ""
for ch in post_part:
if ch.isdigit():
post_digits += ch
else:
break
post = int(post_digits) if post_digits else 0
text = base
parts: list[int] = []
for piece in text.split("."):
digits = ""
for ch in piece:
if ch.isdigit():
digits += ch
else:
break
if digits:
parts.append(int(digits))
while len(parts) < 3:
parts.append(0)
return parts[0], parts[1], parts[2], post
def version_ge(installed: str, required: str) -> bool:
"""Compare semver-like versions without external deps (supports legacy .postN)."""
return _parse_version(installed) >= _parse_version(required)
def _path_under(parent: str | Path, child: str | Path) -> bool:
try:
parent_path = Path(parent).resolve()
child_path = Path(child).resolve()
if sys.platform == "win32":
parent_norm = os.path.normcase(str(parent_path))
child_norm = os.path.normcase(str(child_path))
common = os.path.commonpath([parent_norm, child_norm])
return common == parent_norm
return os.path.commonpath([str(parent_path), str(child_path)]) == str(parent_path)
except (OSError, ValueError):
return False
def is_jiangchang_skill_core_from_skill_tree(
*,
skill_root: str | Path,
core_file: str | Path | None = None,
) -> bool:
"""Return True when jiangchang_skill_core is loaded from inside skill_root."""
try:
root = Path(skill_root)
if not str(root).strip():
return False
core = Path(core_file) if core_file is not None else Path(jiangchang_skill_core.__file__)
return _path_under(root, core)
except (OSError, TypeError, ValueError):
return False
def _platform_kit_version() -> str | None:
try:
return importlib.metadata.version("jiangchang-platform-kit")
except importlib.metadata.PackageNotFoundError:
return None
def _bool_from_env(env: Mapping[str, str], key: str, default: bool = False) -> bool:
val = env.get(key)
if val is None or val == "":
return default
return str(val).strip().lower() in ("1", "true", "yes", "on")
def collect_runtime_diagnostics(
*,
skill_slug: str,
platform_kit_min_version: str | None = None,
skill_root: str | Path | None = None,
record_video: bool | None = None,
env: Mapping[str, str] | None = None,
) -> RuntimeDiagnostics:
env_map = _env_dict(env)
issues: list[RuntimeIssue] = []
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
resolved_data_root = _resolve_data_root(env_map)
media_root_path = resolve_media_assets_root(resolved_data_root, env_map)
media_status = probe_media_assets(resolved_data_root, env_map)
ffmpeg_path_obj = media_status.ffmpeg_path
ffmpeg_ok = ffmpeg_path_obj is not None and ffmpeg_path_obj.is_file()
music_probe = probe_background_music(media_root_path, env=env_map)
mp3_count = int(music_probe["mp3_count"])
usable_count = int(music_probe["usable_count"])
music_issue = music_probe.get("issue")
music_sample = music_probe.get("sample_path")
if music_issue is None and not media_status.music_ready and media_status.warnings:
for warning in media_status.warnings:
if warning.startswith("background_music") or warning == "music_dir_missing":
music_issue = warning
break
platform_version = _platform_kit_version()
platform_ok: bool | None
if platform_kit_min_version is None:
platform_ok = None
elif platform_version is None:
platform_ok = False
else:
platform_ok = version_ge(platform_version, platform_kit_min_version)
if platform_version is None:
issues.append(
RuntimeIssue(
code="platform_kit_not_installed",
message="jiangchang-platform-kit package metadata not found",
severity="error",
)
)
elif platform_kit_min_version is not None and not platform_ok:
issues.append(
RuntimeIssue(
code="platform_kit_version_low",
message=(
f"jiangchang-platform-kit version {platform_version} "
f"is below required {platform_kit_min_version}"
),
severity="error",
)
)
core_file: str | None
try:
core_file = os.path.abspath(jiangchang_skill_core.__file__)
except (OSError, TypeError, ValueError):
core_file = None
if skill_root is not None and core_file is not None:
if is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=core_file):
issues.append(
RuntimeIssue(
code="jiangchang_skill_core_loaded_from_skill_tree",
message=_SHARED_CORE_HINT,
severity="warning",
)
)
if record_video is None:
if env is not None:
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
else:
record_video_enabled = config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
else:
record_video_enabled = record_video
if not ffmpeg_ok:
severity = "warning"
message = "ffmpeg not available"
if record_video_enabled:
message = "OPENCLAW_RECORD_VIDEO enabled but ffmpeg is not available"
issues.append(
RuntimeIssue(
code="ffmpeg_unavailable",
message=message,
severity=severity,
)
)
if record_video_enabled and music_issue:
issues.append(
RuntimeIssue(
code="background_music_unavailable",
message=f"background music unavailable: {music_issue}",
severity="warning",
)
)
return RuntimeDiagnostics(
skill_slug=skill_slug,
python_executable=sys.executable,
platform_kit_version=platform_version,
platform_kit_min_version=platform_kit_min_version,
platform_kit_version_ok=platform_ok,
jiangchang_skill_core_file=core_file,
jiangchang_data_root=jiangchang_data_root,
resolved_data_root=resolved_data_root,
media_assets_root=str(media_root_path),
ffmpeg_available=ffmpeg_ok,
ffmpeg_path=str(ffmpeg_path_obj) if ffmpeg_path_obj else None,
background_music_mp3_count=mp3_count,
background_music_usable_count=usable_count,
background_music_issue=str(music_issue) if music_issue else None,
background_music_sample_path=str(music_sample) if music_sample else None,
record_video_enabled=record_video_enabled,
issues=tuple(issues),
)
def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
lines = [
f"skill_slug: {diagnostics.skill_slug}",
f"python_executable: {diagnostics.python_executable}",
f"platform_kit_version: {diagnostics.platform_kit_version or ''}",
f"platform_kit_min_version: {diagnostics.platform_kit_min_version or ''}",
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}",
f"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
f"media_assets_root: {diagnostics.media_assets_root or ''}",
f"ffmpeg_available: {diagnostics.ffmpeg_available}",
f"ffmpeg_path: {diagnostics.ffmpeg_path or ''}",
f"background_music_mp3_count: {diagnostics.background_music_mp3_count}",
f"background_music_usable_count: {diagnostics.background_music_usable_count}",
f"background_music_issue: {diagnostics.background_music_issue or ''}",
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
f"record_video_enabled: {diagnostics.record_video_enabled}",
]
for issue in diagnostics.issues:
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
return lines
def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
return {
"skill_slug": diagnostics.skill_slug,
"python_executable": diagnostics.python_executable,
"platform_kit_version": diagnostics.platform_kit_version,
"platform_kit_min_version": diagnostics.platform_kit_min_version,
"platform_kit_version_ok": diagnostics.platform_kit_version_ok,
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
"resolved_data_root": diagnostics.resolved_data_root,
"media_assets_root": diagnostics.media_assets_root,
"ffmpeg_available": diagnostics.ffmpeg_available,
"ffmpeg_path": diagnostics.ffmpeg_path,
"background_music_mp3_count": diagnostics.background_music_mp3_count,
"background_music_usable_count": diagnostics.background_music_usable_count,
"background_music_issue": diagnostics.background_music_issue,
"background_music_sample_path": diagnostics.background_music_sample_path,
"record_video_enabled": diagnostics.record_video_enabled,
"runtime_issues": [
{"code": issue.code, "message": issue.message, "severity": issue.severity}
for issue in diagnostics.issues
],
}
def log_runtime_diagnostics(
diagnostics: RuntimeDiagnostics,
logger: logging.Logger | None = None,
level: int = logging.INFO,
) -> None:
log = logger or logging.getLogger("jiangchang_skill_core.runtime_diagnostics")
for line in format_runtime_health_lines(diagnostics):
log.log(level, line)

View File

@@ -44,6 +44,20 @@ def get_user_id() -> str:
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
def get_runs_dir() -> str:
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
return os.path.join(get_data_root(), ".jiangchang", "runs")
def ensure_runs_dir() -> str:
path = get_runs_dir()
try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
return path
def _looks_like_skills_root(path: str) -> bool: def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path): if not path or not os.path.isdir(path):
return False return False
@@ -55,6 +69,7 @@ def _looks_like_skills_root(path: str) -> bool:
"toutiao-publisher", "toutiao-publisher",
"logistics-tracker", "logistics-tracker",
"api-key-vault", "api-key-vault",
"receive-order",
): ):
if os.path.isdir(os.path.join(path, marker)): if os.path.isdir(os.path.join(path, marker)):
return True return True
@@ -67,15 +82,13 @@ def get_skills_root() -> str:
优先级: 优先级:
1) JIANGCHANG_SKILLS_ROOT 1) JIANGCHANG_SKILLS_ROOT
2) CLAW_SKILLS_ROOT 2) JIANGCHANG_APP_ROOT{APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
3) JIANGCHANG_APP_ROOT{APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根 3) Windows默认 D:\\AI\\jiangchang 同上规则
4) Windows默认 D:\\AI\\jiangchang 同上规则 4) 其他平台:~/.openclaw/skills
5) 其他平台:~/.openclaw/skills
""" """
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
v = (os.getenv(key) or "").strip() if v:
if v: return os.path.normpath(v)
return os.path.normpath(v)
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
if sys.platform == "win32" and not app: if sys.platform == "win32" and not app:
@@ -101,8 +114,8 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
""" """
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能 编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」), 则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。 避免全局 JIANGCHANG_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
推断失败时再读上述环境变量,最后回落 get_skills_root()。 推断失败时再读 JIANGCHANG_SKILLS_ROOT,最后回落 get_skills_root()。
""" """
if skill_scripts_dir: if skill_scripts_dir:
scripts = os.path.abspath(skill_scripts_dir) scripts = os.path.abspath(skill_scripts_dir)
@@ -111,10 +124,9 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
if _looks_like_skills_root(inferred): if _looks_like_skills_root(inferred):
return os.path.normpath(inferred) return os.path.normpath(inferred)
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
v = (os.getenv(key) or "").strip() if v:
if v: return os.path.normpath(v)
return os.path.normpath(v)
return get_skills_root() return get_skills_root()

View File

@@ -0,0 +1,71 @@
"""
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
account-manager 等兄弟技能为同步 CLI非 async、非线程池。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from typing import Any, List, Optional, Sequence
from .runtime_env import get_skills_root
from .unified_logging import subprocess_env_with_trace
def get_sibling_main_path(skill_slug: str) -> str:
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
def _python_exe() -> str:
return sys.executable
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
main_path = get_sibling_main_path(skill_slug)
cmd = [_python_exe(), main_path, *argv]
return subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=subprocess_env_with_trace(),
)
def _parse_json_from_stdout(stdout: str) -> Any:
for line in reversed((stdout or "").splitlines()):
stripped = line.strip()
if not stripped or stripped.startswith("ERROR:"):
continue
try:
return json.loads(stripped)
except json.JSONDecodeError:
continue
return None
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
"""Run sibling CLI; return last JSON object line from stdout, or None."""
result = _run_sibling(skill_slug, argv)
if result.returncode != 0:
return None
parsed = _parse_json_from_stdout(result.stdout or "")
if isinstance(parsed, dict):
return parsed
return None
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
"""Run sibling CLI; return last JSON array line from stdout, or None."""
result = _run_sibling(skill_slug, argv)
if result.returncode != 0:
return None
parsed = _parse_json_from_stdout(result.stdout or "")
if isinstance(parsed, list):
return parsed
return None

View File

@@ -10,13 +10,16 @@ import os
import sys import sys
import uuid import uuid
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from typing import Optional from typing import Optional, Tuple
from .runtime_env import get_data_root, get_user_id from .runtime_env import get_data_root, get_user_id
_skill_slug: str = "" _skill_slug: str = ""
_logger_name: str = "" _logger_name: str = ""
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
logging.raiseExceptions = False
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")
@@ -41,6 +44,19 @@ def get_skill_log_file_path() -> str:
return os.path.join(get_unified_logs_dir(), "jiangchang.log") return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def _can_open_log_file(log_path: str) -> Tuple[bool, str]:
"""探测日志文件是否可追加写入(真实 open非 os.access"""
try:
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
with open(log_path, "a", encoding="utf-8"):
pass
return True, ""
except Exception as exc:
return False, str(exc)
def ensure_trace_for_process() -> str: def ensure_trace_for_process() -> str:
"""本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承""" """本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承"""
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
@@ -87,8 +103,17 @@ def _backup_count() -> int:
return 30 return 30
def _attach_fallback_handler(log: logging.Logger, fmt: logging.Formatter) -> None: def _clear_logger_handlers(log: logging.Logger) -> None:
"""文件日志不可用时仅挂内存 handler不向 stderr 打印 logging 异常。""" for h in list(log.handlers):
log.removeHandler(h)
try:
h.close()
except Exception:
pass
def _attach_fallback_handler(log: logging.Logger) -> None:
"""文件日志不可用时仅挂 NullHandler不向 stderr 打印 logging 异常。"""
log.addHandler(logging.NullHandler()) log.addHandler(logging.NullHandler())
@@ -96,37 +121,39 @@ def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
""" """
幂等:为指定 logger 挂载统一文件日志与可选 stderr。 幂等:为指定 logger 挂载统一文件日志与可选 stderr。
须在进程早期调用(如 CLI main 在业务日志之前)。 须在进程早期调用(如 CLI main 在业务日志之前)。
文件日志目录无权限或不可写时降级为 no-op,不影响 CLI 返回码。 文件日志不可写时降级为 NullHandler,不影响 CLI 返回码。
""" """
global _skill_slug, _logger_name global _skill_slug, _logger_name
logging.raiseExceptions = False
ensure_trace_for_process() ensure_trace_for_process()
_skill_slug = skill_slug _skill_slug = skill_slug
_logger_name = logger_name _logger_name = logger_name
log = logging.getLogger(logger_name) log = logging.getLogger(logger_name)
if log.handlers: _clear_logger_handlers(log)
return
log.setLevel(_log_level_from_env()) log.setLevel(_log_level_from_env())
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
filt = _SkillContextFilter() filt = _SkillContextFilter()
try: log_path = get_skill_log_file_path()
path = get_skill_log_file_path() ok, _err = _can_open_log_file(log_path)
fh = TimedRotatingFileHandler( if ok:
path, try:
when="midnight", fh = TimedRotatingFileHandler(
interval=1, log_path,
backupCount=_backup_count(), when="midnight",
encoding="utf-8", interval=1,
delay=True, backupCount=_backup_count(),
) encoding="utf-8",
fh.setFormatter(fmt) delay=False,
fh.addFilter(filt) )
log.addHandler(fh) fh.setFormatter(fmt)
except (OSError, PermissionError, ValueError): fh.addFilter(filt)
_attach_fallback_handler(log, fmt) log.addHandler(fh)
except Exception: except Exception:
_attach_fallback_handler(log, fmt) _attach_fallback_handler(log)
else:
_attach_fallback_handler(log)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ( if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1", "1",
@@ -162,23 +189,25 @@ def attach_unified_file_handler(
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。 独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
""" """
global _skill_slug global _skill_slug
logging.raiseExceptions = False
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() _clear_logger_handlers(lg)
lg.setLevel(level) lg.setLevel(level)
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
try: filt = _SkillContextFilter()
parent = os.path.dirname(os.path.abspath(log_path))
if parent: ok, _err = _can_open_log_file(log_path)
os.makedirs(parent, exist_ok=True) if ok:
fh = logging.FileHandler(log_path, encoding="utf-8") try:
fh.setFormatter(fmt) fh = logging.FileHandler(log_path, encoding="utf-8", delay=False)
fh.addFilter(_SkillContextFilter()) fh.setFormatter(fmt)
lg.addHandler(fh) fh.addFilter(filt)
except (OSError, PermissionError, ValueError): lg.addHandler(fh)
lg.addHandler(logging.NullHandler()) except Exception:
except Exception: _attach_fallback_handler(lg)
lg.addHandler(logging.NullHandler()) else:
_attach_fallback_handler(lg)
lg.propagate = False lg.propagate = False
return lg return lg

View File

@@ -1,16 +1,18 @@
"""FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。""" """FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。"""
from __future__ import annotations from __future__ import annotations
import random
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from jiangchang_skill_core.media_assets import (
background_music_issue,
pick_background_music,
resolve_ffmpeg,
)
# 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性 # 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性
# FontSize 141080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感
# Outline 214 号字配 Outline 3 会显胖2 已足够在浅色背景上保持可读
# 修改请评估对所有 skill 录屏的影响。
_JIANGCHANG_SUBTITLE_STYLE = ( _JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14," "FontName=Arial,FontSize=14,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000," "PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
@@ -18,63 +20,155 @@ _JIANGCHANG_SUBTITLE_STYLE = (
) )
@dataclass
class ComposeVideoResult:
output_path: str
warnings: list[str] = field(default_factory=list)
def _escape_srt_path(path: str) -> str:
return path.replace("\\", "/").replace(":", "\\:")
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
cmd = [str(ffmpeg_exe), *cmd_tail]
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 _media_assets_env(
media_assets_root: str | Path | None,
media_assets_env: dict[str, str] | None,
) -> dict[str, str] | None:
if media_assets_root is None and media_assets_env is None:
return None
env = dict(media_assets_env or {})
if media_assets_root is not None:
env["MEDIA_ASSETS_ROOT"] = str(media_assets_root)
return env
def compose_video( def compose_video(
frames_dir: str, frames_dir: str,
fps: int, fps: int,
subtitle_path: str, subtitle_path: str,
music_dir: str,
output_path: str, output_path: str,
*,
music_volume: float = 0.15, music_volume: float = 0.15,
) -> str: allow_no_music: bool = True,
frames_dir = Path(frames_dir) media_assets_root: str | Path | None = None,
subtitle_path = Path(subtitle_path) media_assets_env: dict[str, str] | None = None,
output_path = Path(output_path) ) -> ComposeVideoResult:
output_path.parent.mkdir(parents=True, exist_ok=True) """合成 MP4ffmpeg 与背景音乐均走 jiangchang_skill_core.media_assets。"""
frames_dir_path = Path(frames_dir)
subtitle_path_obj = Path(subtitle_path)
output_path_obj = Path(output_path)
output_path_obj.parent.mkdir(parents=True, exist_ok=True)
warnings: list[str] = []
# 随机选一首背景音乐 assets_env = _media_assets_env(media_assets_root, media_assets_env)
music_files = list(Path(music_dir).rglob("*.mp3"))
if not music_files:
raise FileNotFoundError(f"music_dir 下没有找到 MP3 文件: {music_dir}")
music_file = random.choice(music_files)
print(f"[screencast] 背景音乐: {music_file.name}")
# FFmpeg 字幕路径在 Windows 下需要转义冒号subtitles 滤镜内部语法) ffmpeg_exe = resolve_ffmpeg(env=assets_env)
srt_path_str = str(subtitle_path).replace("\\", "/").replace(":", "\\:") if ffmpeg_exe is None or not Path(ffmpeg_exe).is_file():
raise RuntimeError("ffmpeg_not_found")
ffmpeg_path = Path(ffmpeg_exe)
music_file = pick_background_music(env=assets_env)
if music_file is None:
issue = background_music_issue(env=assets_env)
if issue:
warnings.append(issue)
else:
warnings.append("background_music_missing")
if not allow_no_music:
raise FileNotFoundError(warnings[-1])
srt_esc = _escape_srt_path(str(subtitle_path_obj.resolve()))
subtitle_filter = ( subtitle_filter = (
f"subtitles='{srt_path_str}'" f"subtitles='{srt_esc}'"
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'" f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
) )
# 视频和音频都放进 filter_complex避免 -vf 与 -filter_complex 混用报错 if music_file and music_file.is_file():
filter_complex = ( filter_complex = (
f"[0:v]{subtitle_filter}[vout];" f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]" f"[1:a]volume={music_volume},apad[aout]"
) )
cmd_tail = [
"-y",
"-framerate",
str(fps),
"-i",
str(frames_dir_path / "frame_%06d.png"),
"-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_obj),
]
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
if not ok:
warnings.append(f"ffmpeg_with_music_failed: {err}")
if not allow_no_music:
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
music_file = None
else:
print(f"[screencast] 背景音乐: {music_file.name}")
print(f"[screencast] 输出: {output_path_obj}")
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
cmd = [ cmd_tail = [
"ffmpeg", "-y", "-y",
"-framerate", str(fps), "-framerate",
"-i", str(frames_dir / "frame_%06d.png"), str(fps),
"-i", str(music_file), "-i",
"-filter_complex", filter_complex, str(frames_dir_path / "frame_%06d.png"),
"-map", "[vout]", "-vf",
"-map", "[aout]", subtitle_filter,
"-c:v", "libx264", "-c:v",
"-preset", "fast", "libx264",
"-crf", "23", "-preset",
"-c:a", "aac", "fast",
"-b:a", "128k", "-crf",
"-shortest", "23",
"-pix_fmt", "yuv420p", "-pix_fmt",
str(output_path), "yuv420p",
str(output_path_obj),
] ]
print("[screencast] 运行 FFmpeg 合成(无背景音乐)...")
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
if not ok:
print(err, file=sys.stderr)
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
print(f"[screencast] 运行 FFmpeg 合成...") print(f"[screencast] 输出: {output_path_obj}")
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
if result.returncode != 0:
print(result.stderr, file=sys.stderr)
raise RuntimeError(f"FFmpeg 合成失败,退出码 {result.returncode}")
print(f"[screencast] 输出: {output_path}")
return str(output_path)

View File

@@ -30,6 +30,8 @@ def _ensure_sdk_on_path() -> None:
_ensure_sdk_on_path() _ensure_sdk_on_path()
from jiangchang_desktop_sdk.window_win32 import activate_and_maximize_jiangchang_window # noqa: E402 from jiangchang_desktop_sdk.window_win32 import activate_and_maximize_jiangchang_window # noqa: E402
import warnings
from .composer import compose_video from .composer import compose_video
from .recorder import ScreenRecorder from .recorder import ScreenRecorder
from .subtitle import SubtitleEngine from .subtitle import SubtitleEngine
@@ -54,10 +56,11 @@ def run_screencast(
subtitle_script: [(关键词, 字幕文案), ...] 按顺序匹配 pytest stdout subtitle_script: [(关键词, 字幕文案), ...] 按顺序匹配 pytest stdout
pytest_args: 传递给 pytest 的参数列表 pytest_args: 传递给 pytest 的参数列表
output_dir: 最终 MP4 输出目录 output_dir: 最终 MP4 输出目录
media_assets_root: media-assets 仓库本地路径; media_assets_root: media-assets 根目录;不传时使用
不传时读 MEDIA_ASSETS_ROOT 环境变量, ``resolve_media_assets_root()`` 默认路径
再不存在则用默认值 D:\\OpenClaw\\client-commons\\media-assets JIANGCHANG_DATA_ROOT/shared/media-assets 或 MEDIA_ASSETS_ROOT
music_subdir: music_assets_root 下音乐子目录,默认 "music" music_subdir: **已废弃**,保留仅为兼容旧调用;背景音乐由
``media_assets.pick_background_music()`` 统一解析
fps: 截帧帧率,默认 10 fps: 截帧帧率,默认 10
activate_window: 录屏开始前是否自动激活+最大化匠厂窗口(默认 True activate_window: 录屏开始前是否自动激活+最大化匠厂窗口(默认 True
Windows 走 ctypes非 Windows 走 jiangchang:// 协议) Windows 走 ctypes非 Windows 走 jiangchang:// 协议)
@@ -67,13 +70,14 @@ def run_screencast(
Returns: Returns:
输出 MP4 的绝对路径 输出 MP4 的绝对路径
""" """
if media_assets_root is None: if music_subdir != "music":
media_assets_root = os.environ.get( warnings.warn(
"MEDIA_ASSETS_ROOT", "music_subdir is deprecated and ignored; "
r"D:\OpenClaw\client-commons\media-assets", "background music is resolved via media_assets.pick_background_music()",
DeprecationWarning,
stacklevel=2,
) )
music_dir = str(Path(media_assets_root) / music_subdir)
output_dir_path = Path(output_dir) output_dir_path = Path(output_dir)
output_dir_path.mkdir(parents=True, exist_ok=True) output_dir_path.mkdir(parents=True, exist_ok=True)
@@ -135,8 +139,8 @@ def run_screencast(
frames_dir=frames_dir, frames_dir=frames_dir,
fps=fps, fps=fps,
subtitle_path=str(subtitle_file), subtitle_path=str(subtitle_file),
music_dir=music_dir,
output_path=str(output_mp4), output_path=str(output_mp4),
media_assets_root=media_assets_root,
) )
return str(output_mp4) return str(output_mp4)

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""Skill Activity Stream: journal, flush, job_id, finish stdout, truncation."""
from __future__ import annotations
import io
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from jiangchang_skill_core import activity
from jiangchang_skill_core.runtime_env import get_runs_dir
class _ActivityEnvIsolation(unittest.TestCase):
_KEYS = (
"JIANGCHANG_DATA_ROOT",
"JIANGCHANG_USER_ID",
"JIANGCHANG_JOB_ID",
"JIANGCHANG_RUN_MODE",
"JIANGCHANG_ACTIVITY_DEBUG",
)
def setUp(self) -> None:
self._saved = {k: os.environ.get(k) for k in self._KEYS}
self._tmps: list[str] = []
for k in self._KEYS:
os.environ.pop(k, None)
activity._reset_for_tests()
def tearDown(self) -> None:
activity._reset_for_tests()
for k, v in self._saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
for tmp in self._tmps:
shutil.rmtree(tmp, ignore_errors=True)
def _tmp_data_root(self) -> str:
tmp = tempfile.mkdtemp()
self._tmps.append(tmp)
return tmp
class TestActivityJournal(_ActivityEnvIsolation):
def test_emit_writes_ndjson_with_meta(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-test-1"
activity._reset_for_tests()
activity.emit("步骤一", type="activity", skill="geo-batch")
journal = Path(get_runs_dir()) / "job-test-1.jsonl"
self.assertTrue(journal.is_file())
lines = journal.read_text(encoding="utf-8").strip().splitlines()
self.assertGreaterEqual(len(lines), 2)
meta = json.loads(lines[0])
self.assertEqual(meta["type"], "meta")
self.assertEqual(meta["job_id"], "job-test-1")
event = json.loads(lines[1])
self.assertEqual(event["type"], "activity")
self.assertEqual(event["text"], "步骤一")
self.assertEqual(event["schema_version"], 1)
self.assertEqual(event["skill"], "geo-batch")
self.assertIn("ts", event)
def test_emit_flushes_immediately(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-flush"
activity._reset_for_tests()
activity.emit("flush test")
journal = Path(get_runs_dir()) / "job-flush.jsonl"
self.assertGreater(journal.stat().st_size, 0)
def test_job_id_from_env(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "env-job-42"
activity._reset_for_tests()
self.assertEqual(activity.get_job_id(), "env-job-42")
def test_job_id_generated_and_cached(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
activity._reset_for_tests()
jid1 = activity.get_job_id()
jid2 = activity.get_job_id()
self.assertEqual(jid1, jid2)
self.assertEqual(len(jid1), 32)
def test_debug_skipped_without_env(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-no-debug"
activity._reset_for_tests()
activity.emit("hidden", type="debug")
journal = Path(get_runs_dir()) / "job-no-debug.jsonl"
self.assertFalse(journal.exists())
def test_debug_written_when_enabled(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-debug"
os.environ["JIANGCHANG_ACTIVITY_DEBUG"] = "1"
activity._reset_for_tests()
activity.emit("dbg", type="debug")
journal = Path(get_runs_dir()) / "job-debug.jsonl"
content = journal.read_text(encoding="utf-8")
self.assertIn('"type":"debug"', content.replace(" ", ""))
def test_emit_never_prints_stdout(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-quiet"
activity._reset_for_tests()
buf = io.StringIO()
with patch("sys.stdout", buf):
activity.emit("silent step")
self.assertEqual(buf.getvalue(), "")
def test_get_run_mode_default_cli(self) -> None:
self.assertEqual(activity.get_run_mode(), "cli")
def test_get_run_mode_job(self) -> None:
os.environ["JIANGCHANG_RUN_MODE"] = "job"
self.assertEqual(activity.get_run_mode(), "job")
class TestActivityFinish(_ActivityEnvIsolation):
def test_finish_stdout_single_line_result(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-finish"
activity._reset_for_tests()
buf = io.StringIO()
with patch("sys.stdout", buf):
activity.finish(status="success", message="ok", skill="geo", generated=3)
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
self.assertEqual(len(lines), 1)
result = json.loads(lines[0])
self.assertEqual(result["type"], "result")
self.assertEqual(result["schema_version"], 1)
self.assertEqual(result["status"], "success")
self.assertEqual(result["skill"], "geo")
self.assertEqual(result["generated"], 3)
journal = Path(get_runs_dir()) / "job-finish.jsonl"
journal_lines = journal.read_text(encoding="utf-8").strip().splitlines()
last = json.loads(journal_lines[-1])
self.assertEqual(last["type"], "done")
self.assertEqual(last["status"], "success")
def test_finish_failed_writes_error_event(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-fail"
activity._reset_for_tests()
with patch("sys.stdout", io.StringIO()):
activity.finish(status="failed", message="boom")
journal = Path(get_runs_dir()) / "job-fail.jsonl"
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
self.assertEqual(last["type"], "error")
def test_finish_truncates_large_result(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "job-trunc"
activity._reset_for_tests()
big = {f"field_{i}": "x" * 200 for i in range(50)}
buf = io.StringIO()
with patch("sys.stdout", buf):
activity.finish(status="success", skill="big", **big)
line = buf.getvalue().strip()
self.assertLessEqual(len(line), 4096)
result = json.loads(line)
self.assertTrue(result.get("truncated"))
class TestJobContext(_ActivityEnvIsolation):
def test_job_context_auto_finish_on_exception(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
activity._reset_for_tests()
with patch("sys.stdout", io.StringIO()):
with self.assertRaises(ValueError):
with activity.job_context(job_id="ctx-err", skill="test-skill"):
raise ValueError("broken")
journal = Path(get_runs_dir()) / "ctx-err.jsonl"
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
self.assertEqual(last["type"], "error")
self.assertIn("broken", last["message"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""rpa_step decorator: sync + async."""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from jiangchang_skill_core import activity
from jiangchang_skill_core.runtime_env import get_runs_dir
class _DecoratorEnvIsolation(unittest.TestCase):
def setUp(self) -> None:
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
self._tmps: list[str] = []
os.environ.pop("JIANGCHANG_JOB_ID", None)
activity._reset_for_tests()
def tearDown(self) -> None:
activity._reset_for_tests()
if self._saved_root is None:
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
else:
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
if self._saved_job is None:
os.environ.pop("JIANGCHANG_JOB_ID", None)
else:
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
for tmp in self._tmps:
shutil.rmtree(tmp, ignore_errors=True)
def _tmp_data_root(self) -> str:
tmp = tempfile.mkdtemp()
self._tmps.append(tmp)
return tmp
class TestRpaStepDecorator(_DecoratorEnvIsolation):
def _journal_texts(self, job_id: str) -> list[str]:
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
lines = journal.read_text(encoding="utf-8").strip().splitlines()
events = [json.loads(ln) for ln in lines if json.loads(ln).get("type") != "meta"]
return [e["text"] for e in events]
def test_sync_success(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-ok"
activity._reset_for_tests()
@activity.rpa_step("打开浏览器")
def open_browser() -> str:
return "ok"
self.assertEqual(open_browser(), "ok")
texts = self._journal_texts("rpa-sync-ok")
self.assertIn("▶ 打开浏览器", texts)
self.assertIn("✓ 打开浏览器", texts)
def test_sync_failure_emits_warn_and_reraises(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-fail"
activity._reset_for_tests()
@activity.rpa_step("提交表单")
def submit() -> None:
raise RuntimeError("timeout")
with self.assertRaises(RuntimeError):
submit()
journal = Path(get_runs_dir()) / "rpa-sync-fail.jsonl"
content = journal.read_text(encoding="utf-8")
self.assertIn("timeout", content)
self.assertIn('"type":"warn"', content.replace(" ", ""))
def test_async_success(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-ok"
activity._reset_for_tests()
@activity.rpa_step("异步步骤")
async def async_step() -> int:
await asyncio.sleep(0)
return 42
result = asyncio.run(async_step())
self.assertEqual(result, 42)
texts = self._journal_texts("rpa-async-ok")
self.assertIn("▶ 异步步骤", texts)
self.assertIn("✓ 异步步骤", texts)
def test_async_failure(self) -> None:
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-fail"
activity._reset_for_tests()
@activity.rpa_step()
async def fail_step() -> None:
await asyncio.sleep(0)
raise ValueError("async err")
with self.assertRaises(ValueError):
asyncio.run(fail_step())
journal = Path(get_runs_dir()) / "rpa-async-fail.jsonl"
self.assertIn("async err", journal.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()

View File

@@ -81,6 +81,27 @@ class TestConfig(unittest.TestCase):
self.assertEqual(config.get_float("BAD", 9.9), 9.9) self.assertEqual(config.get_float("BAD", 9.9), 9.9)
self.assertEqual(config.get_int("BAD", 7), 7) self.assertEqual(config.get_int("BAD", 7), 7)
def test_merge_missing_env_keys_appends_only_new(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
example = os.path.join(tmp, ".env.example")
dest = os.path.join(tmp, ".env")
with open(example, "w", encoding="utf-8") as f:
f.write("EXISTING=1\nNEW_KEY=abc\n")
with open(dest, "w", encoding="utf-8") as f:
f.write("EXISTING=keep\n")
added = config.merge_missing_env_keys(
example,
dest,
comment_skill="demo-skill",
)
self.assertEqual(added, ["NEW_KEY"])
with open(dest, encoding="utf-8") as f:
content = f.read()
self.assertIn("EXISTING=keep", content)
self.assertIn("NEW_KEY=abc", content)
self.assertIn("Added by demo-skill", content)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

437
tests/test_media_assets.py Normal file
View File

@@ -0,0 +1,437 @@
# -*- coding: utf-8 -*-
"""media_assets 共享媒体资源解析测试。"""
from __future__ import annotations
import json
import platform
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
from jiangchang_skill_core import media_assets as ma
def _make_complete_assets(root: Path, *, with_ffmpeg: bool = True, with_ffprobe: bool = True) -> None:
(root / "music" / "calm").mkdir(parents=True)
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
(root / "fonts").mkdir(parents=True)
(root / "watermark").mkdir(parents=True)
bin_key = ma._platform_bin_key()
bin_dir = root / "bin" / bin_key
bin_dir.mkdir(parents=True)
if with_ffmpeg:
(bin_dir / ma._tool_filename("ffmpeg")).write_bytes(b"ffmpeg")
if with_ffprobe:
(bin_dir / ma._tool_filename("ffprobe")).write_bytes(b"ffprobe")
def _sample_manifest() -> dict:
bin_key = ma._platform_bin_key()
return {
"name": "media-assets",
"version": "0.2.0",
"directories": {"music": "music", "fonts": "fonts", "watermark": "watermark"},
"tools": {
"ffmpeg": {
bin_key: {
"package": "ffmpeg-release-essentials",
"archive_type": "zip",
"urls": [{"name": "test", "url": "https://example.test/ffmpeg.zip"}],
"archive_paths": {
"ffmpeg": "*/bin/ffmpeg.exe",
"ffprobe": "*/bin/ffprobe.exe",
},
}
}
},
}
def _install_tools_from_manifest(root: Path, warnings: list[str]) -> bool:
bin_key = ma._platform_bin_key()
bin_dir = root / "bin" / bin_key
bin_dir.mkdir(parents=True, exist_ok=True)
(bin_dir / ma._tool_filename("ffmpeg")).write_bytes(b"ffmpeg")
(bin_dir / ma._tool_filename("ffprobe")).write_bytes(b"ffprobe")
return True
def _build_archive_zip(dest: Path, inner_name: str) -> None:
manifest = _sample_manifest()
with zipfile.ZipFile(dest, "w") as zf:
zf.writestr(f"{inner_name}/README.md", "# media-assets\n")
zf.writestr(f"{inner_name}/manifest.json", json.dumps(manifest, ensure_ascii=False))
zf.writestr(f"{inner_name}/music/calm/track.mp3", b"x" * 1024)
zf.writestr(f"{inner_name}/fonts/.keep", "")
zf.writestr(f"{inner_name}/watermark/.keep", "")
def test_default_bundle_url_is_release() -> None:
url = ma._media_assets_bundle_url()
assert url == ma.MEDIA_ASSETS_BUNDLE_URL
assert url.endswith("/releases/download/vlatest/media-assets.zip")
assert "/archive/main.zip" not in url
def test_bundle_url_env_override() -> None:
override = "https://example.com/media-assets.zip"
assert ma._media_assets_bundle_url(env={"MEDIA_ASSETS_BUNDLE_URL": override}) == override
def test_zip_download_uses_bundle_url_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
data_root = tmp_path / "data"
shared = data_root / "shared"
target = shared / "media-assets"
archive = tmp_path / "media-assets.zip"
override = "https://example.com/media-assets.zip"
_build_archive_zip(archive, "media-assets")
captured_urls: list[str] = []
def _fake_download(url: str, dest: Path) -> None:
captured_urls.append(url)
dest.write_bytes(archive.read_bytes())
monkeypatch.setattr(ma, "_download_zip", _fake_download)
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
env = {
"JIANGCHANG_DATA_ROOT": str(data_root),
"MEDIA_ASSETS_BUNDLE_URL": override,
}
status = ma.ensure_media_assets(env=env)
assert captured_urls == [override]
assert target.is_dir()
assert status.ready is True
def test_media_assets_root_env_priority(tmp_path: Path) -> None:
custom = tmp_path / "custom-media"
custom.mkdir()
env = {
"MEDIA_ASSETS_ROOT": str(custom),
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
}
assert ma.resolve_media_assets_root(env=env) == custom
def test_default_media_assets_path(tmp_path: Path) -> None:
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
expected = tmp_path / "data" / "shared" / "media-assets"
assert ma.resolve_media_assets_root(env=env) == expected
def test_complete_assets_skips_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root)
def _fail_download(*args, **kwargs):
raise AssertionError("download should not be called")
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
monkeypatch.setattr(ma, "_try_download_via_git", _fail_download)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.ready is True
assert status.warnings == []
assert status.source == "local"
def test_missing_ffmpeg_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root, with_ffmpeg=False)
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.ready is False
assert "ffmpeg_missing" in status.warnings
def test_missing_ffprobe_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root, with_ffprobe=False)
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.ready is False
assert "ffprobe_missing" in status.warnings
def test_pick_background_music_stable_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root)
music = root / "music"
(music / "b-upbeat.mp3").write_bytes(b"x" * 1024)
(music / "calm" / "a-calm.mp3").write_bytes(b"x" * 1024)
(music / "z-last.wav").write_bytes(b"x" * 1024)
monkeypatch.setattr(
ma,
"ensure_media_assets",
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
)
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert picked == music / "b-upbeat.mp3"
@pytest.mark.parametrize("inner_name", ["media-assets", "media-assets-main"])
def test_zip_wrapper_layout_detected(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
inner_name: str,
) -> None:
data_root = tmp_path / "data"
shared = data_root / "shared"
target = shared / "media-assets"
archive = tmp_path / "media-assets.zip"
_build_archive_zip(archive, inner_name)
def _fake_download(url: str, dest: Path) -> None:
dest.write_bytes(archive.read_bytes())
monkeypatch.setattr(ma, "_download_zip", _fake_download)
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(data_root)})
assert target.is_dir()
assert status.ready is True
assert status.source == "zip"
assert "media_assets_zip_invalid_layout" not in status.warnings
def test_zip_download_failure_returns_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import urllib.error
def _raise_url_error(url: str, dest: Path) -> None:
raise urllib.error.URLError("network down")
monkeypatch.setattr(ma, "_download_zip", _raise_url_error)
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.ready is False
assert "media_assets_zip_download_failed" in status.warnings
assert "git_not_available" in status.warnings
def test_git_not_available_when_zip_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import urllib.error
monkeypatch.setattr(
ma,
"_download_zip",
lambda url, dest: (_ for _ in ()).throw(urllib.error.URLError("fail")),
)
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert "git_not_available" in status.warnings
def test_media_assets_root_override_no_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
custom = tmp_path / "override"
custom.mkdir()
def _fail_download(*args, **kwargs):
raise AssertionError("download should not be called when MEDIA_ASSETS_ROOT is set")
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
status = ma.ensure_media_assets(env={"MEDIA_ASSETS_ROOT": str(custom)})
assert status.root == custom
assert status.exists is True
@pytest.mark.skipif(platform.system().lower() != "windows", reason="Windows default path check")
def test_windows_default_data_root() -> None:
with patch.dict("os.environ", {}, clear=True):
root = ma.resolve_media_assets_root()
assert root == Path(r"D:\jiangchang-data") / "shared" / "media-assets"
def test_probe_media_assets_does_not_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def _fail_download(*args, **kwargs):
raise AssertionError("download should not be called")
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
monkeypatch.setattr(ma, "_try_download_via_git", _fail_download)
status = ma.probe_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.exists is False
assert status.ffmpeg_path is None
def test_lfs_pointer_warning_in_probe_and_ensure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root)
music = root / "music"
(music / "calm" / "track.mp3").unlink()
(music / "lfs-only.mp3").write_text(
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
)
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
probed = ma.probe_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert "background_music_lfs_pointer_only" in probed.warnings
assert probed.music_ready is False
ensured = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert "background_music_lfs_pointer_only" in ensured.warnings
assert ensured.ready is False
def test_pick_background_music_skips_lfs_pointer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root, with_ffmpeg=True, with_ffprobe=True)
music = root / "music"
(music / "calm" / "track.mp3").unlink()
(music / "real.mp3").write_bytes(b"x" * 1024)
lfs = music / "lfs-only.mp3"
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
monkeypatch.setattr(
ma,
"ensure_media_assets",
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
)
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert picked == music / "real.mp3"
def test_pick_background_music_no_valid_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
_make_complete_assets(root)
music = root / "music"
(music / "calm" / "track.mp3").unlink()
(music / "pointer.mp3").write_text(
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
)
monkeypatch.setattr(
ma,
"ensure_media_assets",
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
)
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert picked is None
def test_video_finalize_without_music_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import asyncio
import os
import tempfile
from pathlib import Path
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
ffmpeg_exe = tmp_path / "ffmpeg.exe"
ffmpeg_exe.write_bytes(b"ffmpeg")
def _fake_resolve_ffmpeg(*args, **kwargs):
return ffmpeg_exe
def _fake_pick_music(*args, **kwargs):
return None
monkeypatch.setattr(
"jiangchang_skill_core.rpa.video_session.resolve_ffmpeg",
_fake_resolve_ffmpeg,
)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.video_session.pick_background_music",
_fake_pick_music,
)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.video_session.background_music_issue",
lambda *a, **k: None,
)
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
try:
config.reset_cache()
with tempfile.TemporaryDirectory() as skill_data:
session = RpaVideoSession(
skill_slug="receive-order",
skill_data_dir=skill_data,
batch_id="batch_no_music",
)
capture = Path(session._capture_path)
capture.parent.mkdir(parents=True, exist_ok=True)
capture.write_bytes(b"\x00" * 64)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.video_session._compose_capture_mp4",
lambda *a, **k: (True, ""),
)
asyncio.run(session.finalize())
assert "background_music_missing" in session.warnings
assert session.output_video_path is not None
finally:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
config.reset_cache()
def test_manifest_ffmpeg_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "data" / "shared" / "media-assets"
(root / "music" / "calm").mkdir(parents=True)
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
(root / "fonts").mkdir(parents=True)
(root / "watermark").mkdir(parents=True)
(root / "manifest.json").write_text(
json.dumps(_sample_manifest(), ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
assert status.ready is True
assert status.ffmpeg_path is not None
assert status.ffprobe_path is not None
def test_is_usable_audio_file_rejects_small_and_lfs(tmp_path: Path) -> None:
small = tmp_path / "small.mp3"
small.write_bytes(b"x" * 512)
assert ma.is_usable_audio_file(small) is False
ok = tmp_path / "ok.mp3"
ok.write_bytes(b"x" * 1024)
assert ma.is_usable_audio_file(ok) is True
lfs = tmp_path / "lfs.mp3"
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
assert ma.is_usable_audio_file(lfs) is False
assert ma.is_git_lfs_pointer(lfs) is True
def test_probe_background_music_structure(tmp_path: Path) -> None:
root = tmp_path / "media-assets"
music = root / "music" / "calm"
music.mkdir(parents=True)
(music / "track.mp3").write_bytes(b"x" * 1024)
probe = ma.probe_background_music(root)
assert probe["music_root"] == str(root / "music")
assert probe["mp3_count"] == 1
assert probe["usable_count"] == 1
assert probe["issue"] is None
assert probe["sample_path"] == str(music / "track.mp3")

View File

@@ -1,4 +1,4 @@
"""Text-level checks that reusable-release-skill packages .env.example at package root.""" """Text-level checks for release-related GitHub Actions workflows."""
from __future__ import annotations from __future__ import annotations
@@ -8,17 +8,52 @@ import re
import pytest import pytest
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_WORKFLOW_PATH = os.path.join( _SKILL_WORKFLOW_PATH = os.path.join(
_REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml" _REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml"
) )
_PUBLISH_WORKFLOW_PATH = os.path.join(
_REPO_ROOT, ".github", "workflows", "publish.yml"
)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def workflow_text() -> str: def workflow_text() -> str:
with open(_WORKFLOW_PATH, encoding="utf-8") as f: with open(_SKILL_WORKFLOW_PATH, encoding="utf-8") as f:
return f.read() return f.read()
@pytest.fixture(scope="module")
def publish_workflow_text() -> str:
with open(_PUBLISH_WORKFLOW_PATH, encoding="utf-8") as f:
return f.read()
def test_workflow_packages_readme_at_root(workflow_text: str) -> None:
assert "readme_src = os.path.abspath('README.md')" in workflow_text
assert "readme_dst = os.path.join(PACKAGE, 'README.md')" in workflow_text
assert "Copied README.md" in workflow_text
assert (
"raise RuntimeError('README.md exists in skill root but was not packaged')"
in workflow_text
)
def test_workflow_metadata_reads_root_readme(workflow_text: str) -> None:
assert "readme_path = 'README.md'" in workflow_text
assert "frontmatter.load(readme_path)" in workflow_text
assert "metadata['readme_md'] = body" in workflow_text
def test_workflow_does_not_use_references_readme_for_marketplace(
workflow_text: str,
) -> None:
assert "os.path.join('references', 'README.md')" not in workflow_text
assert not re.search(
r"frontmatter\.load\([^)]*references[^)]*README",
workflow_text,
)
def test_workflow_packages_env_example_at_root(workflow_text: str) -> None: def test_workflow_packages_env_example_at_root(workflow_text: str) -> None:
assert ".env.example" in workflow_text assert ".env.example" in workflow_text
assert "Copied .env.example" in workflow_text assert "Copied .env.example" in workflow_text
@@ -40,3 +75,18 @@ def test_workflow_does_not_package_dot_env(workflow_text: str) -> None:
r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)", r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)",
workflow_text, workflow_text,
) )
def test_publish_workflow_triggers_on_main_only(publish_workflow_text: str) -> None:
assert "branches:" in publish_workflow_text
assert "- main" in publish_workflow_text
assert "tags:" not in publish_workflow_text
def test_publish_workflow_uses_pyproject_version_only(publish_workflow_text: str) -> None:
assert "ci_set_package_version" not in publish_workflow_text
assert "GITHUB_RUN_NUMBER" not in publish_workflow_text
assert "GITHUB_RUN_ID" not in publish_workflow_text
assert ".post" not in publish_workflow_text
assert "python3.12 -m build" in publish_workflow_text
assert "twine upload" in publish_workflow_text

View File

@@ -0,0 +1,390 @@
# -*- coding: utf-8 -*-
"""runtime_diagnostics 公共 Runtime / media-assets 诊断测试。"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
import jiangchang_skill_core
from jiangchang_skill_core import media_assets as ma
from jiangchang_skill_core.runtime_diagnostics import (
RuntimeDiagnostics,
RuntimeIssue,
collect_runtime_diagnostics,
format_runtime_health_lines,
is_jiangchang_skill_core_from_skill_tree,
runtime_diagnostics_dict,
version_ge,
)
def test_version_ge_post_release() -> None:
assert version_ge("1.0.10.post23", "1.0.10") is True
assert version_ge("1.0.10", "1.0.10") is True
assert version_ge("1.0.9.post19", "1.0.10") is False
assert version_ge("1.0.10.post23", "1.0.10.post21") is True
assert version_ge("1.0.10.post21", "1.0.10.post23") is False
assert version_ge("1.0.10", "1.0.10.post1") is False
assert version_ge("1.0.11", "1.0.10.post99") is True
assert version_ge("1.0.11", "1.0.10") is True
def test_is_jiangchang_skill_core_from_skill_tree(tmp_path: Path) -> None:
skill_root = tmp_path / "my-skill"
inside = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
inside.parent.mkdir(parents=True)
inside.write_text("# vendored\n", encoding="utf-8")
outside = tmp_path / "site-packages" / "jiangchang_skill_core" / "__init__.py"
outside.parent.mkdir(parents=True)
outside.write_text("# shared\n", encoding="utf-8")
assert is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root,
core_file=inside,
) is True
assert is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root,
core_file=outside,
) is False
def test_collect_runtime_diagnostics_json_serializable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
data_root = tmp_path / "data"
env = {
"JIANGCHANG_DATA_ROOT": str(data_root),
"OPENCLAW_RECORD_VIDEO": "0",
}
media_root = data_root / "shared" / "media-assets"
(media_root / "music").mkdir(parents=True)
def _fake_probe_media_assets(data_root_arg=None, env_arg=None):
return ma.MediaAssetsStatus(
root=media_root,
exists=True,
ready=False,
source="local",
warnings=["ffmpeg_missing"],
ffmpeg_path=None,
music_dir=media_root / "music",
music_ready=False,
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
_fake_probe_media_assets,
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": str(media_root / "music"),
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": "background_music_dir_empty",
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.0.10",
)
diag = collect_runtime_diagnostics(
skill_slug="my-skill",
platform_kit_min_version="1.0.9",
env=env,
)
payload = runtime_diagnostics_dict(diag)
json.dumps(payload)
assert payload["skill_slug"] == "my-skill"
assert payload["resolved_data_root"] == str(data_root)
assert payload["platform_kit_version_ok"] is True
def test_platform_kit_min_version_none_skips_version_issue(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=tmp_path,
exists=False,
ready=False,
source="local",
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": "music_dir_missing",
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.0.8",
)
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
assert diag.platform_kit_version_ok is None
assert "platform_kit_version_low" not in diag.issue_codes()
def test_platform_kit_missing_records_issue(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=tmp_path,
exists=False,
ready=False,
source="local",
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": None,
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: None,
)
diag = collect_runtime_diagnostics(
skill_slug="my-skill",
platform_kit_min_version="1.0.10",
env=env,
)
assert "platform_kit_not_installed" in diag.issue_codes()
def test_record_video_ffmpeg_and_music_warnings_not_fatal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
media_root = tmp_path / "shared" / "media-assets"
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path), "OPENCLAW_RECORD_VIDEO": "1"}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=media_root,
exists=True,
ready=False,
source="local",
warnings=["ffmpeg_missing", "music_dir_missing"],
ffmpeg_path=None,
music_dir=None,
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": "music_dir_missing",
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.0.10",
)
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
codes = diag.issue_codes()
assert "ffmpeg_unavailable" in codes
assert "background_music_unavailable" in codes
assert diag.has_fatal_issues is False
ffmpeg_issue = next(i for i in diag.issues if i.code == "ffmpeg_unavailable")
assert ffmpeg_issue.severity == "warning"
def test_skill_tree_core_issue_when_skill_root_given(tmp_path: Path) -> None:
skill_root = tmp_path / "my-skill"
fake_core = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
fake_core.parent.mkdir(parents=True)
fake_core.write_text("# vendored\n", encoding="utf-8")
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
with patch.object(jiangchang_skill_core, "__file__", str(fake_core)):
diag = collect_runtime_diagnostics(
skill_slug="my-skill",
skill_root=skill_root,
record_video=False,
env=env,
)
assert "jiangchang_skill_core_loaded_from_skill_tree" in diag.issue_codes()
def _legacy_env_key(suffix: str) -> str:
return "CL" + "AW_" + suffix
def test_format_runtime_health_lines_no_legacy_data_root_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env = {
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
_legacy_env_key("DATA_ROOT"): str(tmp_path / "legacy-only"),
}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=tmp_path,
exists=False,
ready=False,
source="local",
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": None,
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.0.17",
)
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
payload = runtime_diagnostics_dict(diag)
text = "\n".join(format_runtime_health_lines(diag))
legacy_key = _legacy_env_key("DATA_ROOT")
assert legacy_key not in payload
assert legacy_key not in text
assert payload["resolved_data_root"] == str(tmp_path / "data")
def test_format_runtime_health_lines_core_fields(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=tmp_path,
exists=False,
ready=False,
source="local",
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": None,
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.0.10",
)
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
text = "\n".join(format_runtime_health_lines(diag))
for marker in (
"python_executable:",
"platform_kit_version:",
"resolved_data_root:",
"media_assets_root:",
"ffmpeg_available:",
"background_music_mp3_count:",
):
assert marker in text
def test_background_music_lfs_and_size_rules(tmp_path: Path) -> None:
music = tmp_path / "music"
music.mkdir()
(music / "tiny.mp3").write_bytes(b"x" * 512)
(music / "lfs.mp3").write_text(
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
)
(music / "ok.mp3").write_bytes(b"x" * 1024)
probe = ma.probe_background_music(tmp_path)
assert probe["mp3_count"] == 3
assert probe["usable_count"] == 1
assert probe["sample_path"] == str(music / "ok.mp3")
def test_runtime_diagnostics_frozen_dataclass() -> None:
diag = RuntimeDiagnostics(
skill_slug="x",
python_executable="python",
platform_kit_version="1.0.10",
platform_kit_min_version=None,
platform_kit_version_ok=None,
jiangchang_skill_core_file=None,
jiangchang_data_root=None,
resolved_data_root="/tmp",
media_assets_root="/tmp/media",
ffmpeg_available=False,
ffmpeg_path=None,
background_music_mp3_count=0,
background_music_usable_count=0,
background_music_issue=None,
background_music_sample_path=None,
record_video_enabled=False,
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
)
assert diag.has_fatal_issues is False
assert diag.issue_codes() == ["ffmpeg_unavailable"]
def test_format_runtime_health_lines_issue_separator_ascii() -> None:
diag = RuntimeDiagnostics(
skill_slug="x",
python_executable="python",
platform_kit_version="1.0.11",
platform_kit_min_version=None,
platform_kit_version_ok=None,
jiangchang_skill_core_file=None,
jiangchang_data_root=None,
resolved_data_root="/tmp",
media_assets_root="/tmp/media",
ffmpeg_available=False,
ffmpeg_path=None,
background_music_mp3_count=0,
background_music_usable_count=0,
background_music_issue=None,
background_music_sample_path=None,
record_video_enabled=False,
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
)
lines = format_runtime_health_lines(diag)
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
assert len(issue_lines) == 1
assert " - " in issue_lines[0]
assert "\u2014" not in issue_lines[0]
assert "\u2013" not in issue_lines[0]

204
tests/test_runtime_env.py Normal file
View File

@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
"""runtime_envJIANGCHANG_* 路径契约与兄弟技能根解析。"""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from jiangchang_skill_core import config, unified_logging as ul
from jiangchang_skill_core.runtime_diagnostics import (
collect_runtime_diagnostics,
format_runtime_health_lines,
runtime_diagnostics_dict,
)
from jiangchang_skill_core.runtime_env import (
apply_cli_local_defaults,
get_data_root,
get_sibling_skills_root,
get_skills_root,
get_user_id,
platform_default_data_root,
)
def _legacy_env_key(suffix: str) -> str:
return "CL" + "AW_" + suffix
class _EnvIsolation(unittest.TestCase):
_KEYS = (
"JIANGCHANG_DATA_ROOT",
"JIANGCHANG_USER_ID",
"JIANGCHANG_SKILLS_ROOT",
"JIANGCHANG_APP_ROOT",
_legacy_env_key("DATA_ROOT"),
_legacy_env_key("USER_ID"),
_legacy_env_key("SKILLS_ROOT"),
"JIANGCHANG_CLI_LOCAL_DEV",
)
def setUp(self) -> None:
self._saved = {k: os.environ.get(k) for k in self._KEYS}
for k in self._KEYS:
os.environ.pop(k, None)
config.reset_cache()
def tearDown(self) -> None:
for k, v in self._saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
config.reset_cache()
class TestGetDataRoot(_EnvIsolation):
def test_jiangchang_data_root_only(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
self.assertEqual(get_data_root(), tmp)
def test_platform_default_when_unset(self) -> None:
self.assertEqual(get_data_root(), platform_default_data_root())
def test_legacy_claw_data_root_ignored(self) -> None:
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
os.environ["JIANGCHANG_DATA_ROOT"] = jc_tmp
self.assertEqual(get_data_root(), jc_tmp)
def test_legacy_claw_data_root_alone_ignored(self) -> None:
with tempfile.TemporaryDirectory() as claw_tmp:
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
self.assertEqual(get_data_root(), platform_default_data_root())
class TestGetUserId(_EnvIsolation):
def test_jiangchang_user_id_only(self) -> None:
os.environ["JIANGCHANG_USER_ID"] = "user-42"
self.assertEqual(get_user_id(), "user-42")
def test_anon_when_unset(self) -> None:
self.assertEqual(get_user_id(), "_anon")
def test_legacy_claw_user_id_ignored(self) -> None:
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
os.environ["JIANGCHANG_USER_ID"] = "jc-user"
self.assertEqual(get_user_id(), "jc-user")
def test_legacy_claw_user_id_alone_ignored(self) -> None:
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
self.assertEqual(get_user_id(), "_anon")
class TestApplyCliLocalDefaults(_EnvIsolation):
def test_sets_jiangchang_only(self) -> None:
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
apply_cli_local_defaults()
self.assertTrue(os.environ.get("JIANGCHANG_DATA_ROOT"))
self.assertTrue(os.environ.get("JIANGCHANG_USER_ID"))
self.assertNotIn(_legacy_env_key("DATA_ROOT"), os.environ)
self.assertNotIn(_legacy_env_key("USER_ID"), os.environ)
def test_does_not_override_existing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_USER_ID"] = "existing"
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
apply_cli_local_defaults()
self.assertEqual(os.environ["JIANGCHANG_DATA_ROOT"], tmp)
self.assertEqual(os.environ["JIANGCHANG_USER_ID"], "existing")
class TestGetSkillsRoot(_EnvIsolation):
def test_jiangchang_skills_root_override(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_SKILLS_ROOT"] = tmp
self.assertEqual(get_skills_root(), os.path.normpath(tmp))
def test_legacy_claw_skills_root_ignored(self) -> None:
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
self.assertEqual(get_skills_root(), os.path.normpath(jc_tmp))
def test_legacy_claw_skills_root_alone_ignored(self) -> None:
with tempfile.TemporaryDirectory() as claw_tmp:
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
root = get_skills_root()
self.assertNotEqual(root, os.path.normpath(claw_tmp))
class TestGetSiblingSkillsRoot(_EnvIsolation):
def _make_skills_tree(self, base: Path) -> Path:
skills_root = base / "skills"
(skills_root / "account-manager").mkdir(parents=True)
(skills_root / "receive-order" / "scripts").mkdir(parents=True)
return skills_root
def test_inference_preferred_over_env(self) -> None:
with tempfile.TemporaryDirectory() as tree_tmp, tempfile.TemporaryDirectory() as env_tmp:
skills_root = self._make_skills_tree(Path(tree_tmp))
scripts_dir = str(skills_root / "receive-order" / "scripts")
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
result = get_sibling_skills_root(scripts_dir)
self.assertEqual(result, os.path.normpath(str(skills_root)))
def test_env_override_when_inference_fails(self) -> None:
with tempfile.TemporaryDirectory() as env_tmp:
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
result = get_sibling_skills_root("/nonexistent/skill/scripts")
self.assertEqual(result, os.path.normpath(env_tmp))
def test_legacy_claw_skills_root_ignored_for_sibling(self) -> None:
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
result = get_sibling_skills_root("/nonexistent/skill/scripts")
self.assertEqual(result, os.path.normpath(jc_tmp))
class TestConfigAndLoggingPaths(_EnvIsolation):
def test_config_env_file_under_user_isolated_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_USER_ID"] = "u-test"
example = os.path.join(tmp, ".env.example")
with open(example, "w", encoding="utf-8") as f:
f.write("FOO=bar\n")
dest = config.ensure_env_file("my-skill", example)
expected_prefix = os.path.join(tmp, "u-test", "my-skill")
self.assertTrue(os.path.normpath(dest).startswith(os.path.normpath(expected_prefix)))
def test_unified_logging_path_under_user_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_USER_ID"] = "log-user"
log_dir = ul.get_unified_logs_dir()
self.assertTrue(
os.path.normpath(log_dir).startswith(
os.path.normpath(os.path.join(tmp, "log-user", "logs"))
)
)
class TestDiagnosticsNoClawFields(_EnvIsolation):
def test_diagnostics_json_and_text_exclude_claw(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
diag = collect_runtime_diagnostics(skill_slug="x", env=dict(os.environ))
payload = runtime_diagnostics_dict(diag)
text = "\n".join(format_runtime_health_lines(diag))
legacy_data_root_key = _legacy_env_key("DATA_ROOT")
self.assertNotIn(legacy_data_root_key, payload)
self.assertNotIn(legacy_data_root_key, text)
self.assertIn("JIANGCHANG_DATA_ROOT", payload)
self.assertEqual(payload["resolved_data_root"], tmp)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
"""screencast composer 媒体资源集成测试。"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from screencast.composer import ComposeVideoResult, compose_video
def _write_frame(frames_dir: Path) -> None:
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_000001.png").write_bytes(b"png")
def _write_srt(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("1\n00:00:00,000 --> 00:00:02,000\nhello\n", encoding="utf-8")
def test_compose_video_uses_absolute_ffmpeg(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
ffmpeg = tmp_path / "ffmpeg.exe"
ffmpeg.write_bytes(b"ffmpeg")
_write_frame(frames)
_write_srt(srt)
captured: list[list[str]] = []
def _fake_run(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
captured.append([str(ffmpeg_exe), *cmd_tail])
output.write_bytes(b"mp4")
return True, ""
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
"screencast.composer.pick_background_music",
return_value=None,
), patch("screencast.composer.background_music_issue", return_value="background_music_missing"), patch(
"screencast.composer._run_ffmpeg",
side_effect=_fake_run,
):
result = compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
)
assert isinstance(result, ComposeVideoResult)
assert captured
assert captured[0][0] == str(ffmpeg)
assert "background_music_missing" in result.warnings
def test_compose_video_with_music(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
ffmpeg = tmp_path / "ffmpeg.exe"
music = tmp_path / "music.mp3"
ffmpeg.write_bytes(b"ffmpeg")
music.write_bytes(b"x" * 256)
_write_frame(frames)
_write_srt(srt)
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
"screencast.composer.pick_background_music",
return_value=music,
), patch(
"screencast.composer._run_ffmpeg",
return_value=(True, ""),
):
result = compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
)
output.write_bytes(b"mp4")
assert result.warnings == []
def test_compose_video_music_failure_fallback(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
ffmpeg = tmp_path / "ffmpeg.exe"
music = tmp_path / "music.mp3"
ffmpeg.write_bytes(b"ffmpeg")
music.write_bytes(b"x" * 256)
_write_frame(frames)
_write_srt(srt)
calls = {"count": 0}
def _fake_run(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
calls["count"] += 1
if calls["count"] == 1:
return False, "music mux failed"
output.write_bytes(b"mp4")
return True, ""
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
"screencast.composer.pick_background_music",
return_value=music,
), patch(
"screencast.composer._run_ffmpeg",
side_effect=_fake_run,
):
result = compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
)
assert any("ffmpeg_with_music_failed" in w for w in result.warnings)
assert calls["count"] == 2
def test_compose_video_passes_media_assets_root_to_media_apis(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
custom_root = tmp_path / "custom-media-assets"
custom_root.mkdir()
ffmpeg = custom_root / "ffmpeg.exe"
ffmpeg.write_bytes(b"ffmpeg")
_write_frame(frames)
_write_srt(srt)
captured_env: list[dict[str, str] | None] = []
def _capture_env(*args, **kwargs):
captured_env.append(kwargs.get("env"))
if len(captured_env) == 1:
return ffmpeg
if len(captured_env) == 2:
return None
return "background_music_missing"
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
"screencast.composer.pick_background_music",
side_effect=_capture_env,
), patch(
"screencast.composer.background_music_issue",
side_effect=_capture_env,
), patch(
"screencast.composer._run_ffmpeg",
return_value=(True, ""),
):
compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
media_assets_root=str(custom_root),
)
output.write_bytes(b"mp4")
assert len(captured_env) == 3
for env in captured_env:
assert env is not None
assert env["MEDIA_ASSETS_ROOT"] == str(custom_root)
def test_compose_video_default_does_not_set_media_assets_root(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
ffmpeg = tmp_path / "ffmpeg.exe"
ffmpeg.write_bytes(b"ffmpeg")
_write_frame(frames)
_write_srt(srt)
captured_env: list[dict[str, str] | None] = []
def _capture_env(*args, **kwargs):
captured_env.append(kwargs.get("env"))
if len(captured_env) == 1:
return ffmpeg
if len(captured_env) == 2:
return None
return "background_music_missing"
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
"screencast.composer.pick_background_music",
side_effect=_capture_env,
), patch(
"screencast.composer.background_music_issue",
side_effect=_capture_env,
), patch(
"screencast.composer._run_ffmpeg",
return_value=(True, ""),
):
compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
)
assert all(env is None for env in captured_env)
def test_compose_video_media_assets_root_overrides_env(tmp_path: Path) -> None:
frames = tmp_path / "frames"
srt = tmp_path / "out.srt"
output = tmp_path / "final.mp4"
custom_root = tmp_path / "override-root"
custom_root.mkdir()
ffmpeg = custom_root / "ffmpeg.exe"
ffmpeg.write_bytes(b"ffmpeg")
_write_frame(frames)
_write_srt(srt)
original_env = {"MEDIA_ASSETS_ROOT": "/old/path", "OTHER": "keep"}
captured_env: list[dict[str, str] | None] = []
def _capture_env(*args, **kwargs):
captured_env.append(kwargs.get("env"))
if len(captured_env) == 1:
return ffmpeg
if len(captured_env) == 2:
return None
return "background_music_missing"
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
"screencast.composer.pick_background_music",
side_effect=_capture_env,
), patch(
"screencast.composer.background_music_issue",
side_effect=_capture_env,
), patch(
"screencast.composer._run_ffmpeg",
return_value=(True, ""),
):
compose_video(
frames_dir=str(frames),
fps=10,
subtitle_path=str(srt),
output_path=str(output),
media_assets_root=str(custom_root),
media_assets_env=original_env,
)
output.write_bytes(b"mp4")
assert original_env["MEDIA_ASSETS_ROOT"] == "/old/path"
assert captured_env[0]["MEDIA_ASSETS_ROOT"] == str(custom_root)
assert captured_env[0]["OTHER"] == "keep"

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""screencast runner 媒体资源参数传递测试。"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from screencast.runner import run_screencast
def test_run_screencast_passes_media_assets_root_to_compose_video(tmp_path: Path) -> None:
custom_root = tmp_path / "media-assets"
custom_root.mkdir()
captured: dict = {}
mock_recorder = MagicMock()
mock_recorder.frame_count = 1
def _fake_compose_video(**kwargs):
captured.update(kwargs)
Path(kwargs["output_path"]).write_bytes(b"mp4")
from screencast.composer import ComposeVideoResult
return ComposeVideoResult(kwargs["output_path"])
with patch("screencast.runner.activate_and_maximize_jiangchang_window", return_value=True), patch(
"screencast.runner.ScreenRecorder",
return_value=mock_recorder,
), patch("screencast.runner.SubtitleEngine") as subtitle_cls, patch(
"screencast.runner.subprocess.Popen",
) as popen_cls, patch(
"screencast.runner.compose_video",
side_effect=_fake_compose_video,
):
subtitle = subtitle_cls.return_value
proc = popen_cls.return_value
proc.stdout = iter([])
proc.wait.return_value = 0
out = run_screencast(
skill_slug="demo-skill",
subtitle_script=[("ok", "done")],
pytest_args=["-q", "tests/test_demo.py"],
output_dir=str(tmp_path / "out"),
media_assets_root=str(custom_root),
)
assert Path(out).is_file()
assert captured.get("media_assets_root") == str(custom_root)
def test_run_screencast_music_subdir_deprecated_warning() -> None:
with pytest.warns(DeprecationWarning, match="music_subdir is deprecated"):
with patch("screencast.runner.activate_and_maximize_jiangchang_window", return_value=False), patch(
"screencast.runner.ScreenRecorder",
) as rec_cls, patch("screencast.runner.SubtitleEngine"), patch(
"screencast.runner.subprocess.Popen",
) as popen_cls, patch(
"screencast.runner.compose_video",
) as compose_mock:
rec = rec_cls.return_value
rec.frame_count = 0
proc = popen_cls.return_value
proc.stdout = iter([])
proc.wait.return_value = 0
from screencast.composer import ComposeVideoResult
compose_mock.return_value = ComposeVideoResult("out.mp4")
run_screencast(
skill_slug="demo",
subtitle_script=[],
pytest_args=["-q"],
output_dir=".",
music_subdir="calm",
)

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""sibling_bridge: mock subprocess sibling CLI calls."""
from __future__ import annotations
import json
import os
import subprocess
import unittest
from unittest.mock import MagicMock, patch
from jiangchang_skill_core.sibling_bridge import (
call_sibling_json,
call_sibling_json_array,
get_sibling_main_path,
)
class TestSiblingBridge(unittest.TestCase):
def test_get_sibling_main_path(self) -> None:
with patch.dict(os.environ, {"JIANGCHANG_SKILLS_ROOT": r"D:\skills"}):
path = get_sibling_main_path("account-manager")
self.assertTrue(path.replace("\\", "/").endswith("account-manager/scripts/main.py"))
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
def test_call_sibling_json_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout='log line\n{"id": 1, "name": "test"}\n',
stderr="",
)
result = call_sibling_json("account-manager", ["list", "all"])
self.assertEqual(result, {"id": 1, "name": "test"})
cmd = mock_run.call_args[0][0]
self.assertIn("account-manager", cmd[1])
self.assertIn("list", cmd)
self.assertTrue(mock_run.call_args[1]["capture_output"])
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
def test_call_sibling_json_nonzero_exit(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="ERROR:fail"
)
self.assertIsNone(call_sibling_json("content-manager", ["get", "x"]))
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
def test_call_sibling_json_array(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=json.dumps([{"a": 1}, {"b": 2}]),
stderr="",
)
result = call_sibling_json_array("account-manager", ["list", "all"])
self.assertEqual(result, [{"a": 1}, {"b": 2}])
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
def test_uses_subprocess_env_with_trace(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout='{"ok":true}', stderr=""
)
with patch(
"jiangchang_skill_core.sibling_bridge.subprocess_env_with_trace",
return_value={"JIANGCHANG_TRACE_ID": "abc123"},
) as mock_env:
call_sibling_json("api-key-vault", ["get", "key"])
self.assertEqual(mock_run.call_args[1]["env"], {"JIANGCHANG_TRACE_ID": "abc123"})
mock_env.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""统一日志:文件不可写时降级,首次 emit 不抛 PermissionError。"""
from __future__ import annotations
import logging
import os
import tempfile
import unittest
from logging.handlers import TimedRotatingFileHandler
from unittest.mock import patch
from jiangchang_skill_core import unified_logging as ul
class TestUnifiedLogging(unittest.TestCase):
def setUp(self) -> None:
ul._skill_slug = ""
ul._logger_name = ""
def test_can_open_log_file_writable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.log")
ok, err = ul._can_open_log_file(path)
self.assertTrue(ok)
self.assertEqual(err, "")
self.assertTrue(os.path.isfile(path))
def test_setup_skill_logging_writable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
log_path = os.path.join(tmp, "skill.log")
logger_name = "test.logger.writable"
with patch.object(ul, "get_skill_log_file_path", return_value=log_path):
ul.setup_skill_logging("receive-order", logger_name)
ul.get_skill_logger().info("hello")
self.assertTrue(os.path.isfile(log_path))
ul._clear_logger_handlers(logging.getLogger(logger_name))
def test_setup_skill_logging_unwritable_no_file_handler(self) -> None:
logger_name = "test.logger.unwritable"
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
ul.setup_skill_logging("receive-order", logger_name)
lg = logging.getLogger(logger_name)
lg.info("hello")
handlers = logging.getLogger(logger_name).handlers
self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers))
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers))
def test_attach_unified_file_handler_unwritable(self) -> None:
logger_name = "test.logger.attach.unwritable"
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
lg = ul.attach_unified_file_handler(
"/no/access/test.log",
skill_slug="receive-order",
logger_name=logger_name,
)
lg.info("hello")
self.assertFalse(
any(isinstance(h, logging.FileHandler) for h in lg.handlers)
)
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in lg.handlers))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""RpaVideoSessionffmpeg 桌面录屏。""" """RpaVideoSessionffmpeg 桌面录屏、背景音乐循环淡出、旁白混音"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -7,10 +7,22 @@ import os
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import MagicMock, patch from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from jiangchang_skill_core import config from jiangchang_skill_core import config
from jiangchang_skill_core.rpa.video_session import RpaVideoSession from jiangchang_skill_core.rpa import video_session as vs
from jiangchang_skill_core.rpa.video_session import (
RpaVideoSession,
_StepEntry,
_INTRO_BUFFER_SECONDS,
_INTRO_STABILIZE_SECONDS,
_OUTRO_BUFFER_SECONDS,
_compose_capture_mp4,
_music_fade_params,
_select_voiceover_clips,
_voiceover_text_for_step,
)
class TestRpaVideoSession(unittest.TestCase): class TestRpaVideoSession(unittest.TestCase):
@@ -33,6 +45,8 @@ class TestRpaVideoSession(unittest.TestCase):
self.assertIsNone(summ["path"]) self.assertIsNone(summ["path"])
self.assertIsNone(summ["capture_path"]) self.assertIsNone(summ["capture_path"])
self.assertIsNone(summ["record_log_path"]) self.assertIsNone(summ["record_log_path"])
self.assertIsNone(summ["voiceover_path"])
self.assertIsNone(summ["music_path"])
finally: finally:
if old is not None: if old is not None:
os.environ["OPENCLAW_RECORD_VIDEO"] = old os.environ["OPENCLAW_RECORD_VIDEO"] = old
@@ -60,6 +74,11 @@ class TestRpaVideoSession(unittest.TestCase):
"/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log" "/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log"
) )
) )
self.assertTrue(
session._voiceover_path.replace("\\", "/").endswith(
"/rpa-artifacts/exmail_20260602_120000/voiceover/voiceover.wav"
)
)
self.assertTrue( self.assertTrue(
session._planned_output.replace("\\", "/").startswith( session._planned_output.replace("\\", "/").startswith(
tmp.replace("\\", "/") + "/videos/" tmp.replace("\\", "/") + "/videos/"
@@ -70,9 +89,10 @@ class TestRpaVideoSession(unittest.TestCase):
config.reset_cache() config.reset_cache()
@unittest.skipUnless(sys.platform == "win32", "gdigrab desktop capture") @unittest.skipUnless(sys.platform == "win32", "gdigrab desktop capture")
@patch("jiangchang_skill_core.rpa.video_session.shutil.which", return_value="ffmpeg") @patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
@patch("jiangchang_skill_core.rpa.video_session.subprocess.Popen") @patch("jiangchang_skill_core.rpa.video_session.subprocess.Popen")
def test_enter_starts_ffmpeg_gdigrab(self, mock_popen: MagicMock, _which: MagicMock) -> None: def test_enter_starts_ffmpeg_gdigrab(self, mock_popen: MagicMock, mock_resolve: MagicMock) -> None:
mock_resolve.return_value = Path(r"C:\media\bin\win32-x64\ffmpeg.exe")
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = None proc.poll.return_value = None
proc.stdin = MagicMock() proc.stdin = MagicMock()
@@ -96,7 +116,7 @@ class TestRpaVideoSession(unittest.TestCase):
self.assertTrue(mock_popen.called) self.assertTrue(mock_popen.called)
cmd = mock_popen.call_args[0][0] cmd = mock_popen.call_args[0][0]
self.assertIn("ffmpeg", cmd) self.assertTrue(str(cmd[0]).endswith("ffmpeg.exe"))
self.assertIn("gdigrab", cmd) self.assertIn("gdigrab", cmd)
self.assertIn("desktop", cmd) self.assertIn("desktop", cmd)
kwargs = mock_popen.call_args[1] kwargs = mock_popen.call_args[1]
@@ -105,8 +125,8 @@ class TestRpaVideoSession(unittest.TestCase):
os.environ.pop("OPENCLAW_RECORD_VIDEO", None) os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
config.reset_cache() config.reset_cache()
@patch("jiangchang_skill_core.rpa.video_session.shutil.which", return_value=None) @patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe", return_value=None)
def test_ffmpeg_not_found_warning(self, _which: MagicMock) -> None: def test_ffmpeg_not_found_warning(self, _resolve: MagicMock) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1" os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
try: try:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -143,5 +163,413 @@ class TestRpaVideoSession(unittest.TestCase):
config.reset_cache() config.reset_cache()
class TestMusicFadeParams(unittest.TestCase):
def test_normal_duration(self) -> None:
start, dur = _music_fade_params(20.0)
self.assertAlmostEqual(start, 17.0)
self.assertAlmostEqual(dur, 3.0)
def test_short_video(self) -> None:
start, dur = _music_fade_params(4.0)
self.assertAlmostEqual(dur, 1.0)
self.assertAlmostEqual(start, 3.0)
def test_invalid_duration(self) -> None:
self.assertEqual(_music_fade_params(None), (None, None))
self.assertEqual(_music_fade_params(0), (None, None))
self.assertEqual(_music_fade_params(-1), (None, None))
class TestVoiceoverTextFilter(unittest.TestCase):
def test_keeps_meaningful_steps(self) -> None:
for text in (
"开始采集",
"获取1688账号",
"启动浏览器",
"检查登录状态",
"搜索关键词:螺丝",
"解析第 1 页店铺列表",
"打开店铺联系方式页",
"提取联系人信息",
"采集完成",
):
self.assertIsNotNone(_voiceover_text_for_step(text), msg=text)
def test_skips_noise(self) -> None:
for text in (
"等待 1-5s",
"跳过重复店铺",
"写入联系人库",
"探针模式",
"操作异常",
"登录失败",
"2024-01-01 12:00:00 INFO: debug",
"",
" ",
):
self.assertIsNone(_voiceover_text_for_step(text), msg=text)
def test_truncates_long_text(self) -> None:
long_text = "" * 80
result = _voiceover_text_for_step(long_text)
self.assertIsNotNone(result)
assert result is not None
self.assertEqual(len(result), 60)
def test_dedup_within_ten_seconds(self) -> None:
entries = [
_StepEntry(0.0, "启动浏览器"),
_StepEntry(5.0, "启动浏览器"),
_StepEntry(12.0, "启动浏览器"),
]
clips = _select_voiceover_clips(entries)
texts = [t for _, t in clips]
self.assertEqual(texts.count("启动浏览器"), 2)
def test_min_gap_two_seconds(self) -> None:
entries = [
_StepEntry(0.0, "获取1688账号"),
_StepEntry(1.0, "连接CDP"),
_StepEntry(3.0, "检查登录状态"),
]
clips = _select_voiceover_clips(entries)
self.assertEqual(len(clips), 2)
self.assertEqual(clips[0][1], "获取1688账号")
self.assertEqual(clips[1][1], "检查登录状态")
def test_important_bypasses_min_gap(self) -> None:
entries = [
_StepEntry(0.0, "定位搜索框"),
_StepEntry(0.5, "输入关键词:宁德电池"),
_StepEntry(1.0, "点击搜索"),
_StepEntry(1.5, "等待搜索结果"),
]
clips = _select_voiceover_clips(entries)
texts = [t for _, t in clips]
self.assertEqual(len(clips), 4)
self.assertIn("输入关键词:宁德电池", texts)
self.assertIn("点击搜索", texts)
def test_noise_still_skipped_with_important_nearby(self) -> None:
entries = [
_StepEntry(0.0, "点击搜索"),
_StepEntry(0.5, "跳过重复店铺"),
_StepEntry(1.0, "写入联系人库"),
]
clips = _select_voiceover_clips(entries)
texts = [t for _, t in clips]
self.assertEqual(texts, ["点击搜索"])
class TestComposeCaptureMp4(unittest.TestCase):
def setUp(self) -> None:
self.ffmpeg = Path(r"C:\ffmpeg\ffmpeg.exe")
self.capture = Path(r"C:\tmp\capture.mp4")
self.srt = Path(r"C:\tmp\sub.srt")
self.output = Path(r"C:\tmp\out.mp4")
self.music = Path(r"C:\tmp\music.mp3")
self.voice = Path(r"C:\tmp\voiceover.wav")
def _run_compose(self, **kwargs) -> list[str]:
captured: list[str] = []
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
captured.extend(cmd_tail)
return True, ""
with (
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
patch.object(vs, "_resolve_ffprobe_exe", return_value=Path(r"C:\ffmpeg\ffprobe.exe")),
patch.object(vs, "_probe_media_duration_seconds", return_value=kwargs.pop("duration", 20.0)),
patch.object(Path, "is_file", return_value=True),
):
_compose_capture_mp4(
self.ffmpeg,
self.capture,
self.srt,
self.output,
music_file=kwargs.get("music_file"),
voiceover_file=kwargs.get("voiceover_file"),
warnings=kwargs.get("warnings"),
)
return captured
def test_music_loop_params(self) -> None:
cmd = self._run_compose(music_file=self.music)
cmd_str = " ".join(cmd)
loop_idx = cmd.index("-stream_loop")
minus_one_idx = cmd.index("-1", loop_idx)
music_i_idx = cmd.index("-i", minus_one_idx)
self.assertEqual(cmd[music_i_idx + 1], str(self.music))
self.assertIn("-shortest", cmd)
filter_idx = cmd.index("-filter_complex")
filt = cmd[filter_idx + 1]
self.assertNotIn("apad", filt)
def test_music_fade_filter_long_video(self) -> None:
cmd = self._run_compose(music_file=self.music, duration=20.0)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("afade=t=out:st=17", filt)
self.assertIn("d=3", filt)
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
def test_music_fade_filter_short_video(self) -> None:
cmd = self._run_compose(music_file=self.music, duration=4.0)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("afade=t=out:st=3", filt)
self.assertIn("d=1", filt)
def test_duration_probe_failed_no_afade(self) -> None:
warnings: list[str] = []
captured: list[str] = []
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
captured.extend(cmd_tail)
return True, ""
with (
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
patch.object(vs, "_resolve_ffprobe_exe", return_value=None),
patch.object(vs, "_probe_media_duration_seconds", return_value=None),
patch.object(Path, "is_file", return_value=True),
):
ok, _ = _compose_capture_mp4(
self.ffmpeg,
self.capture,
self.srt,
self.output,
music_file=self.music,
warnings=warnings,
)
self.assertTrue(ok)
self.assertIn("video_duration_probe_failed_for_music_fade", warnings)
filt = captured[captured.index("-filter_complex") + 1]
self.assertNotIn("afade", filt)
self.assertIn("-stream_loop", captured)
def test_music_and_voice_amix(self) -> None:
cmd = self._run_compose(music_file=self.music, voiceover_file=self.voice)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("amix", filt)
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
self.assertLess(vs._MUSIC_VOLUME, vs._VOICE_VOLUME)
self.assertIn("afade", filt)
self.assertNotIn("apad", filt)
self.assertIn("-stream_loop", cmd)
self.assertIn("-shortest", cmd)
def test_voice_only_no_amix(self) -> None:
cmd = self._run_compose(voiceover_file=self.voice)
filt = cmd[cmd.index("-filter_complex") + 1]
self.assertNotIn("amix", filt)
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
self.assertIn("apad", filt)
self.assertIn("-shortest", cmd)
self.assertNotIn("-stream_loop", cmd)
class TestVideoSessionLifecycle(unittest.TestCase):
def setUp(self) -> None:
config.reset_cache()
def tearDown(self) -> None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
config.reset_cache()
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
def test_enter_starts_capture_before_title(
self,
mock_finalize: AsyncMock,
mock_stop: MagicMock,
mock_start: MagicMock,
mock_sleep: AsyncMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
call_order: list[str] = []
mock_start.side_effect = lambda: call_order.append("start_capture")
with tempfile.TemporaryDirectory() as tmp:
config.reset_cache()
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch_intro",
title="开始执行测试",
)
original_add = session.add_step
def _track_add(text: str, *, duration: float = 4.0) -> None:
call_order.append(f"add_step:{text}")
original_add(text, duration=duration)
with patch.object(session, "add_step", side_effect=_track_add):
asyncio.run(session.__aenter__())
self.assertEqual(call_order[0], "start_capture")
self.assertIn("add_step:开始执行测试", call_order)
mock_sleep.assert_awaited()
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
def test_enter_no_title_still_buffers(
self,
mock_start: MagicMock,
mock_sleep: AsyncMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
with tempfile.TemporaryDirectory() as tmp:
config.reset_cache()
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch_no_title",
)
with patch.object(session, "add_step") as mock_add:
asyncio.run(session.__aenter__())
mock_start.assert_called_once()
mock_add.assert_not_called()
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
def test_exit_closing_title_before_stop(
self,
mock_finalize: AsyncMock,
mock_stop: MagicMock,
mock_sleep: AsyncMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
call_order: list[str] = []
mock_stop.side_effect = lambda: call_order.append("stop_capture")
with tempfile.TemporaryDirectory() as tmp:
config.reset_cache()
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch_outro",
closing_title="任务已完成",
)
original_add = session.add_step
def _track_add(text: str, *, duration: float = 4.0) -> None:
call_order.append(f"add_step:{text}")
original_add(text, duration=duration)
with patch.object(session, "add_step", side_effect=_track_add):
asyncio.run(session.__aexit__(None, None, None))
self.assertIn("add_step:任务已完成", call_order)
self.assertEqual(call_order[-1], "stop_capture")
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
def test_exit_no_closing_title_still_outro_buffer(
self,
mock_finalize: AsyncMock,
mock_stop: MagicMock,
mock_sleep: AsyncMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
with tempfile.TemporaryDirectory() as tmp:
config.reset_cache()
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch_outro_empty",
)
with patch.object(session, "add_step") as mock_add:
asyncio.run(session.__aexit__(None, None, None))
mock_add.assert_not_called()
mock_stop.assert_called_once()
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
class TestFinalizeDegradation(unittest.TestCase):
def setUp(self) -> None:
config.reset_cache()
def tearDown(self) -> None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
config.reset_cache()
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
def test_tts_failure_still_composes(
self,
mock_compose: MagicMock,
mock_tts: MagicMock,
mock_music: MagicMock,
mock_ffmpeg: MagicMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
mock_music.return_value = Path(r"C:\tmp\music.mp3")
mock_tts.return_value = None
mock_compose.return_value = (True, "")
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
capture.parent.mkdir(parents=True)
capture.write_bytes(b"fake")
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch",
)
session.add_step("启动浏览器")
asyncio.run(session.finalize())
self.assertTrue(mock_compose.called)
self.assertIsNotNone(session.output_video_path)
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
def test_compose_fallback_without_music(
self,
mock_compose: MagicMock,
mock_tts: MagicMock,
mock_music: MagicMock,
mock_ffmpeg: MagicMock,
) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
mock_music.return_value = Path(r"C:\tmp\music.mp3")
mock_tts.return_value = None
mock_compose.side_effect = [(False, "music err"), (True, "")]
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
capture.parent.mkdir(parents=True)
capture.write_bytes(b"fake")
session = RpaVideoSession(
skill_slug="test",
skill_data_dir=tmp,
batch_id="batch",
)
asyncio.run(session.finalize())
self.assertEqual(mock_compose.call_count, 2)
self.assertTrue(any("ffmpeg_with_music_failed" in w for w in session.warnings))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
"""RpaVideoSession.add_step integrates with activity.emit."""
from __future__ import annotations
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from jiangchang_skill_core import activity, config
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from jiangchang_skill_core.runtime_env import get_runs_dir
class TestVideoSessionActivityIntegration(unittest.TestCase):
def setUp(self) -> None:
config.reset_cache()
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
self._saved_video = os.environ.get("OPENCLAW_RECORD_VIDEO")
self._tmps: list[str] = []
activity._reset_for_tests()
def tearDown(self) -> None:
activity._reset_for_tests()
if self._saved_root is None:
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
else:
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
if self._saved_job is None:
os.environ.pop("JIANGCHANG_JOB_ID", None)
else:
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
if self._saved_video is None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
os.environ["OPENCLAW_RECORD_VIDEO"] = self._saved_video
config.reset_cache()
for tmp in self._tmps:
shutil.rmtree(tmp, ignore_errors=True)
def _tmp_data_root(self) -> str:
tmp = tempfile.mkdtemp()
self._tmps.append(tmp)
return tmp
def test_add_step_emits_activity_when_disabled(self) -> None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "video-act-1"
activity._reset_for_tests()
config.reset_cache()
session = RpaVideoSession(
skill_slug="publish-toutiao",
skill_data_dir=tmp,
batch_id="batch1",
)
self.assertFalse(session.enabled)
session.add_step("打开浏览器")
journal = Path(get_runs_dir()) / "video-act-1.jsonl"
self.assertTrue(journal.is_file())
content = journal.read_text(encoding="utf-8")
self.assertIn("打开浏览器", content)
self.assertIn("rpa.video", content)
self.assertIn("publish-toutiao", content)
def test_emit_activity_false_skips_journal(self) -> None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "video-act-off"
activity._reset_for_tests()
session = RpaVideoSession(
skill_slug="publish-toutiao",
skill_data_dir=tmp,
batch_id="batch2",
emit_activity=False,
)
session.add_step("不应写入")
journal = Path(get_runs_dir()) / "video-act-off.jsonl"
self.assertFalse(journal.exists())
def test_add_step_writes_both_journal_and_subtitle_steps(self) -> None:
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
tmp = self._tmp_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
os.environ["JIANGCHANG_JOB_ID"] = "video-act-both"
activity._reset_for_tests()
config.reset_cache()
session = RpaVideoSession(
skill_slug="receive-order",
skill_data_dir=tmp,
batch_id="batch3",
)
session.add_step("录制步骤")
self.assertEqual(len(session._steps), 1)
journal = Path(get_runs_dir()) / "video-act-both.jsonl"
lines = [json.loads(ln) for ln in journal.read_text(encoding="utf-8").splitlines()]
activity_events = [e for e in lines if e.get("type") == "activity"]
self.assertEqual(len(activity_events), 1)
self.assertEqual(activity_events[0]["text"], "录制步骤")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,79 +0,0 @@
"""Patch pyproject.toml and __init__.py version for CI builds (not committed)."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _read_base_version() -> str:
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if not match:
raise SystemExit("ERROR: version not found in pyproject.toml")
base = match.group(1)
return re.sub(r"\.post\d+$", "", base)
def resolve_version() -> str:
ref = os.environ.get("GITHUB_REF", "")
if ref.startswith("refs/tags/v"):
return ref.removeprefix("refs/tags/v")
if ref.startswith("refs/tags/"):
return ref.removeprefix("refs/tags/")
run_number = (
os.environ.get("GITHUB_RUN_NUMBER")
or os.environ.get("GITHUB_RUN_ID")
or "0"
)
base = _read_base_version()
return f"{base}.post{run_number}"
def patch_version(version: str) -> None:
pyproject = ROOT / "pyproject.toml"
py_text = pyproject.read_text(encoding="utf-8")
py_text, n = re.subn(
r'^version\s*=\s*"[^"]*"',
f'version = "{version}"',
py_text,
count=1,
flags=re.MULTILINE,
)
if n != 1:
raise SystemExit("ERROR: failed to patch version in pyproject.toml")
pyproject.write_text(py_text, encoding="utf-8")
init_py = ROOT / "src" / "jiangchang_desktop_sdk" / "__init__.py"
init_text = init_py.read_text(encoding="utf-8")
init_text, n = re.subn(
r'^__version__\s*=\s*"[^"]*"',
f'__version__ = "{version}"',
init_text,
count=1,
flags=re.MULTILINE,
)
if n != 1:
raise SystemExit("ERROR: failed to patch __version__ in __init__.py")
init_py.write_text(init_text, encoding="utf-8")
def main() -> None:
version = resolve_version()
print(f"Resolved package version: {version}")
patch_version(version)
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"package_version={version}\n")
if __name__ == "__main__":
try:
main()
except SystemExit as exc:
print(exc, file=sys.stderr)
raise

View File

@@ -28,6 +28,7 @@
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会: 加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
- 加密 scripts/(递归 PyArmor -r输出到包内 scripts/,与源码目录树一致) - 加密 scripts/(递归 PyArmor -r输出到包内 scripts/,与源码目录树一致)
- 复制 SKILL.md - 复制 SKILL.md
- 若根目录存在 README.md则复制到包根明文用于技能市场用户说明
- 复制 references/(排除 REQUIREMENTS.md - 复制 references/(排除 REQUIREMENTS.md
- 复制 assets/ - 复制 assets/
- 复制 tests/ - 复制 tests/

145
uv.lock generated
View File

@@ -116,6 +116,92 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
] ]
[[package]]
name = "greenlet"
version = "3.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
{ url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.13"
@@ -127,14 +213,60 @@ wheels = [
[[package]] [[package]]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "0.1.0" version = "1.0.15"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mss" },
{ name = "playwright" },
{ name = "requests" }, { name = "requests" },
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }] requires-dist = [
{ name = "mss", specifier = ">=9.0.1" },
{ name = "playwright", specifier = ">=1.42.0" },
{ name = "requests", specifier = ">=2.31.0" },
]
[[package]]
name = "mss"
version = "10.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/eee782a6d674f562c946ae6a026f4c595ea2b7b031f290bf9fbf60da09b5/mss-10.2.0.tar.gz", hash = "sha256:ab271860775545e62f29d7b11f82f279ac1048f5bbdd26cfad84830208dbd393", size = 200317, upload-time = "2026-04-23T10:44:57.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/c3/313e14f245c79b4c05bd0f3a84a4813aa26fa10f8993aebd91d04c5fad3f/mss-10.2.0-py3-none-any.whl", hash = "sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197", size = 67106, upload-time = "2026-04-23T10:44:56.266Z" },
]
[[package]]
name = "playwright"
version = "1.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
@@ -151,6 +283,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
] ]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"