From aab0207aecd08a41c6fc24a908aeae6c36ad5374 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Wed, 3 Jun 2026 19:44:30 +0800 Subject: [PATCH] fix: restore platform kit version to 1.0.9 Co-authored-by: Cursor --- README.md | 12 +- pyproject.toml | 2 +- src/jiangchang_desktop_sdk/__init__.py | 2 +- src/jiangchang_skill_core/media_assets.py | 177 +++++++++++++++++++++- tests/test_media_assets.py | 64 ++++++-- 5 files changed, 234 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7e3df04..e5cfdac 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,15 @@ send_prompt_via_composer(page, "第一行\n第二行") ### main 分支自动预发布 -每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **唯一** 的 post 版本(基于 `pyproject.toml` 中的基础版本,例如 `0.1.0.post42`)。版本号不会写回仓库,仅在 CI 构建前临时改写 `pyproject.toml` 与 `jiangchang_desktop_sdk.__version__`。 +每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **post 预发布版本**(仅供 CI 流水线内部使用,**不是**正式版本线)。 + +正式版本线使用 **1.0.x** semver,通过 git tag 发布。 ```bash git push origin main ``` -测试人员升级安装: +测试人员升级安装(始终安装当前正式 tag 对应的版本,例如 `1.0.9`): ```bash python -m pip install --upgrade \ @@ -117,11 +119,11 @@ python -m pip install --upgrade \ ### 正式版本(tag) -打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v0.1.1` → PyPI 包 `0.1.1`): +打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v1.0.9` → PyPI 包 `1.0.9`): ```bash -git tag v0.1.1 -git push origin v0.1.1 +git tag v1.0.9 +git push origin v1.0.9 ``` ### 安装后验证 diff --git a/pyproject.toml b/pyproject.toml index 9fe77ad..f721d69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jiangchang-platform-kit" -version = "0.1.1" +version = "1.0.9" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/src/jiangchang_desktop_sdk/__init__.py b/src/jiangchang_desktop_sdk/__init__.py index 2a452e1..e85a637 100644 --- a/src/jiangchang_desktop_sdk/__init__.py +++ b/src/jiangchang_desktop_sdk/__init__.py @@ -23,7 +23,7 @@ __all__ = [ "LaunchError", "GatewayDownError", ] -__version__ = "0.1.1" +__version__ = "1.0.9" def __getattr__(name: str): diff --git a/src/jiangchang_skill_core/media_assets.py b/src/jiangchang_skill_core/media_assets.py index 43fe403..7ddba0f 100644 --- a/src/jiangchang_skill_core/media_assets.py +++ b/src/jiangchang_skill_core/media_assets.py @@ -2,6 +2,8 @@ from __future__ import annotations +import fnmatch +import json import os import platform import shutil @@ -110,6 +112,144 @@ def _platform_tool_paths(root: Path) -> tuple[Path, Path]: ) +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() @@ -340,6 +480,17 @@ def _media_assets_root_overridden(env: dict[str, str] | None) -> bool: 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", + ) + ) + + def ensure_media_assets( data_root: str | Path | None = None, env: dict[str, str] | None = None, @@ -352,7 +503,10 @@ def ensure_media_assets( status = _inspect_media_assets(root, source="local") if overridden: - return status + 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 @@ -361,30 +515,39 @@ def ensure_media_assets( if _is_git_repo(root): warnings: list[str] = [] if _try_download_via_git(root, warnings, clone=False): - return _inspect_media_assets(root, source="git") + return _maybe_ensure_ffmpeg_tools( + root, + _inspect_media_assets(root, source="git"), + ) status = _inspect_media_assets(root, source="local") status.warnings.extend(warnings) - return status + 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): - return _inspect_media_assets(root, source="zip") + return _maybe_ensure_ffmpeg_tools( + 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 _inspect_media_assets( + return _maybe_ensure_ffmpeg_tools( root, - source="git" if _is_git_repo(root) else "local", + _inspect_media_assets( + root, + source="git" if _is_git_repo(root) else "local", + ), ) final = _inspect_media_assets(root, source="local") for warning in warnings: if warning not in final.warnings: final.warnings.append(warning) - return final + return _maybe_ensure_ffmpeg_tools(root, final) def resolve_ffmpeg( diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index e8d3f0b..9768e42 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -2,6 +2,7 @@ """media_assets 共享媒体资源解析测试。""" from __future__ import annotations +import json import platform import zipfile from pathlib import Path @@ -25,6 +26,37 @@ def _make_complete_assets(root: Path, *, with_ffmpeg: bool = True, 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 test_media_assets_root_env_priority(tmp_path: Path) -> None: custom = tmp_path / "custom-media" custom.mkdir() @@ -100,20 +132,13 @@ def test_pick_background_music_stable_first(tmp_path: Path, monkeypatch: pytest. 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/.keep", "") zf.writestr(f"{inner_name}/fonts/.keep", "") zf.writestr(f"{inner_name}/watermark/.keep", "") - bin_key = ma._platform_bin_key() - zf.writestr( - f"{inner_name}/bin/{bin_key}/{ma._tool_filename('ffmpeg')}", - b"ffmpeg", - ) - zf.writestr( - f"{inner_name}/bin/{bin_key}/{ma._tool_filename('ffprobe')}", - b"ffprobe", - ) def test_zip_wrapper_layout_detected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -129,6 +154,7 @@ def test_zip_wrapper_layout_detected(tmp_path: Path, monkeypatch: pytest.MonkeyP 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() @@ -185,3 +211,23 @@ 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_manifest_ffmpeg_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "data" / "shared" / "media-assets" + (root / "music").mkdir(parents=True) + (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