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>
This commit is contained in:
2026-06-06 10:32:35 +08:00
parent aab0207aec
commit 434a405e19
14 changed files with 949 additions and 114 deletions

View File

@@ -23,8 +23,11 @@ from .unified_logging import (
)
from .media_assets import (
MediaAssetsStatus,
background_music_issue,
ensure_media_assets,
pick_background_music,
probe_ffmpeg,
probe_media_assets,
resolve_ffmpeg,
resolve_ffprobe,
resolve_media_assets_root,
@@ -43,7 +46,10 @@ __all__ = [
"EntitlementResult",
"EntitlementServiceError",
"MediaAssetsStatus",
"background_music_issue",
"ensure_media_assets",
"probe_ffmpeg",
"probe_media_assets",
"pick_background_music",
"resolve_ffmpeg",
"resolve_ffprobe",

View File

@@ -69,6 +69,69 @@ def reset_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:
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env已存在不覆盖返回路径。"""
global _skill_slug, _example_path, _env_file_path

View File

@@ -27,6 +27,67 @@ _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 = 128
def _is_git_lfs_pointer(path: Path) -> bool:
try:
head = path.read_bytes()[:256]
except OSError:
return False
return head.startswith(_GIT_LFS_POINTER_PREFIX)
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: Path) -> bool:
if not path.is_file():
return False
if path.suffix.lower() not in _AUDIO_EXTENSIONS:
return False
if _is_git_lfs_pointer(path):
return False
try:
if path.stat().st_size < _MIN_AUDIO_BYTES:
return False
except OSError:
return False
return True
@dataclass
@@ -39,6 +100,7 @@ class MediaAssetsStatus:
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
@@ -275,6 +337,13 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
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
@@ -282,6 +351,7 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
and watermark_exists
and ffmpeg_exists
and ffprobe_exists
and music_ready
)
return MediaAssetsStatus(
@@ -293,6 +363,7 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
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,
)
@@ -487,10 +558,43 @@ def _needs_media_repo_content(status: MediaAssetsStatus) -> bool:
"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) -> MediaAssetsStatus:
if not _needs_music_content_repair(status):
return status
if _media_assets_root_overridden(None):
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):
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,
@@ -530,16 +634,22 @@ def ensure_media_assets(
if _try_download_via_zip(shared, root, warnings):
return _maybe_ensure_ffmpeg_tools(
root,
_inspect_media_assets(root, source="zip"),
_maybe_repair_music_content(
root,
_inspect_media_assets(root, source="zip"),
),
)
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,
_inspect_media_assets(
_maybe_repair_music_content(
root,
source="git" if _is_git_repo(root) else "local",
_inspect_media_assets(
root,
source="git" if _is_git_repo(root) else "local",
),
),
)
@@ -547,7 +657,29 @@ def ensure_media_assets(
for warning in warnings:
if warning not in final.warnings:
final.warnings.append(warning)
return _maybe_ensure_ffmpeg_tools(root, final)
repaired = _maybe_repair_music_content(root, final)
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(
@@ -574,6 +706,18 @@ def resolve_music_dir(
return status.music_dir
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,
@@ -583,10 +727,11 @@ def pick_background_music(
if music_dir is None or not music_dir.is_dir():
return None
candidates: list[Path] = []
for path in music_dir.rglob("*"):
if path.is_file() and path.suffix.lower() in _AUDIO_EXTENSIONS:
candidates.append(path)
candidates = [
path
for path in music_dir.rglob("*")
if _is_usable_audio_file(path)
]
if not candidates:
return None

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
import asyncio
import os
import random
import shutil
import subprocess
import sys
import time
@@ -15,6 +13,12 @@ from pathlib import Path
from typing import Any, Dict, IO, List, Optional
from .. import config
from ..media_assets import (
background_music_issue,
ensure_media_assets,
pick_background_music,
resolve_ffmpeg,
)
_JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14,"
@@ -22,11 +26,6 @@ _JIANGCHANG_SUBTITLE_STYLE = (
"Outline=2,Shadow=1,Alignment=2"
)
_DEFAULT_MUSIC_ROOT = os.environ.get(
"MEDIA_ASSETS_ROOT",
r"D:\OpenClaw\client-commons\media-assets",
)
_CAPTURE_FRAMERATE = 15
@@ -34,14 +33,23 @@ def _record_video_enabled() -> bool:
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
def _resolve_music_file() -> Optional[Path]:
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
if not music_dir.is_dir():
def _resolve_ffmpeg_exe() -> Optional[Path]:
ffmpeg = resolve_ffmpeg()
if ffmpeg is None:
return None
files = list(music_dir.rglob("*.mp3"))
if not files:
return None
return random.choice(files)
path = Path(ffmpeg)
return path if path.is_file() else None
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
@@ -87,7 +95,12 @@ def _escape_srt_path(path: str) -> str:
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:
result = subprocess.run(
cmd,
@@ -104,12 +117,12 @@ def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
return True, ""
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
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",
return _ffmpeg_cmd(
ffmpeg_exe,
"-y",
"-f",
"gdigrab",
@@ -124,10 +137,11 @@ def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
"-pix_fmt",
"yuv420p",
capture_path,
]
)
def _compose_capture_mp4(
ffmpeg_exe: Path,
capture_path: Path,
srt_path: Path,
output_path: Path,
@@ -147,8 +161,7 @@ def _compose_capture_mp4(
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]"
)
cmd = [
"ffmpeg",
cmd_tail = [
"-y",
"-i",
str(capture_path),
@@ -176,8 +189,7 @@ def _compose_capture_mp4(
str(output_path),
]
else:
cmd = [
"ffmpeg",
cmd_tail = [
"-y",
"-i",
str(capture_path),
@@ -197,7 +209,7 @@ def _compose_capture_mp4(
"yuv420p",
str(output_path),
]
return _run_ffmpeg(cmd)
return _run_ffmpeg(ffmpeg_exe, cmd_tail)
class RpaVideoSession:
@@ -287,11 +299,13 @@ class RpaVideoSession:
def _start_ffmpeg_capture(self) -> None:
if not self.enabled:
return
if not shutil.which("ffmpeg"):
ffmpeg_exe = _resolve_ffmpeg_exe()
if ffmpeg_exe is None:
self.warnings.append("ffmpeg_not_found")
return
cmd = _build_desktop_record_cmd(self._capture_path)
cmd = _build_desktop_record_cmd(ffmpeg_exe, self._capture_path)
if cmd is None:
self._append_warning(
"ffmpeg_record_start_failed",
@@ -391,14 +405,21 @@ class RpaVideoSession:
except Exception as exc:
self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file()
if music is None:
music = _resolve_music_file(self.warnings)
if music is None and "background_music_lfs_pointer_only" not in self.warnings and "background_music_invalid" not in self.warnings:
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
srt_file = Path(self._srt_path)
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
ok, err = _compose_capture_mp4(
ffmpeg_exe,
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
@@ -407,6 +428,7 @@ class RpaVideoSession:
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_capture_mp4(
ffmpeg_exe,
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
@@ -414,8 +436,8 @@ class RpaVideoSession:
)
if not ok and not use_srt:
ok, err = _run_ffmpeg(
ffmpeg_exe,
[
"ffmpeg",
"-y",
"-i",
str(capture),
@@ -428,7 +450,7 @@ class RpaVideoSession:
"-pix_fmt",
"yuv420p",
str(self._planned_output),
]
],
)
if not ok:
self._append_warning("ffmpeg_compose_failed", err)

View File

@@ -34,14 +34,22 @@ def platform_default_data_root() -> str:
def get_data_root() -> str:
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
env = (
os.getenv("CLAW_DATA_ROOT")
or os.getenv("JIANGCHANG_DATA_ROOT")
or ""
).strip()
if env:
return env
return platform_default_data_root()
def get_user_id() -> str:
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
return (
os.getenv("CLAW_USER_ID")
or os.getenv("JIANGCHANG_USER_ID")
or ""
).strip() or "_anon"
def _looks_like_skills_root(path: str) -> bool:
@@ -55,6 +63,7 @@ def _looks_like_skills_root(path: str) -> bool:
"toutiao-publisher",
"logistics-tracker",
"api-key-vault",
"receive-order",
):
if os.path.isdir(os.path.join(path, marker)):
return True
@@ -130,10 +139,21 @@ def apply_cli_local_defaults() -> None:
enabled = v in ("1", "true", "yes", "on")
if not enabled:
return
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
if not (
os.getenv("CLAW_DATA_ROOT")
or os.getenv("JIANGCHANG_DATA_ROOT")
or ""
).strip():
default_root = platform_default_data_root()
os.environ["JIANGCHANG_DATA_ROOT"] = default_root
os.environ.setdefault("CLAW_DATA_ROOT", default_root)
if not (
os.getenv("CLAW_USER_ID")
or os.getenv("JIANGCHANG_USER_ID")
or ""
).strip():
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
os.environ.setdefault("CLAW_USER_ID", DEFAULT_LOCAL_USER_ID.strip())
def find_chrome_executable() -> "str | None":
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。