diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0683196 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json,toml}] +indent_size = 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f51a8b9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Summary + +- What changed? +- Why is this needed? + +## Scope + +- Affected modules/files: +- Breaking changes: yes/no + +## Validation + +- [ ] `ruff check scripts tests tools` +- [ ] `black --check scripts tests tools` +- [ ] `python -m pytest tests/test_wrapper.py -q` +- [ ] `python tools/check_repo_hygiene.py` +- [ ] `python tools/validate_data_schema.py` + +## Risk and Rollback + +- Risk level: +- Rollback plan: + +## Checklist + +- [ ] Docs updated (`SKILL.md` / `rules/` / `docs/api.md`) if API/behavior changed +- [ ] No runtime artifacts committed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5fafd0c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.2 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + + - repo: local + hooks: + - id: pytest-wrapper + name: pytest (wrapper quick suite) + entry: python -m pytest tests/test_wrapper.py -q + language: system + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7515b40 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +## Branching + +- Use feature branches for all changes. +- Keep `main` releasable. +- Prefer small, focused pull requests. + +## Local Setup + +```bash +pip install -r requirements.txt +pip install pytest ruff black pre-commit +pre-commit install +``` + +## Required Checks + +```bash +ruff check scripts tests tools +black --check scripts tests tools +python -m pytest tests/test_wrapper.py -q +python tools/check_repo_hygiene.py +python tools/validate_data_schema.py +``` + +## Commit Rules + +- Use clear commit messages with intent and scope. +- Include docs updates when behavior or APIs change. +- Do not commit runtime artifacts (`cloud_cache`, `__pycache__`, logs). + +## Pull Request Rules + +- Describe user impact and migration risk. +- Add concrete verification steps. +- Include before/after behavior for API-affecting changes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf235dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 luoluoluo22 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bf8259e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +Security fixes are provided for the latest `main` branch. + +## Reporting a Vulnerability + +Please report vulnerabilities privately by opening a GitHub Security Advisory in this repository. + +Include: + +- Affected component or file path +- Reproduction steps / proof of concept +- Impact assessment +- Suggested remediation (if available) + +Do not disclose details publicly until a fix is available. + +## Response Targets + +- Initial triage: within 72 hours +- Mitigation plan: within 7 days for confirmed issues diff --git a/SKILL.md b/SKILL.md index ef831b4..d7a7d73 100644 --- a/SKILL.md +++ b/SKILL.md @@ -8,6 +8,8 @@ description: 剪映 (JianYing) AI自动化剪辑的高级封装 API (JyWrapper) Use this skill when the user wants to automate video editing, generate drafts, or manipulate media assets in JianYing Pro. Agent execution playbook: [docs/agent-playbook.md](docs/agent-playbook.md) +Minimal command SOP: [docs/minimal-command-sop.md](docs/minimal-command-sop.md) +For generic editing requests, always follow the "Quick Edit Runtime Template" and "Acceptance Checklist" in that playbook. ## 🚨 重要开发原则 (CRITICAL DEVELOPER RULES) 1. **脚本位置**:**禁止在 Skill 内部目录创建剪辑脚本**。所有的剪辑逻辑实现代码(`.py` 脚本)必须存放在用户当前项目的**根目录**(或子目录,如 `scripts/`),以保持 Skill 库的纯净和可移植性。 @@ -98,13 +100,19 @@ import sys # 1. 环境初始化 (必须同步到脚本开头) current_dir = os.path.dirname(os.path.abspath(__file__)) +env_root = os.getenv("JY_SKILL_ROOT", "").strip() # 探测 Skill 路径 (支持 Antigravity, Trae, Claude 等) skill_root = next((p for p in [ + env_root, os.path.join(current_dir, ".agent", "skills", "jianying-editor"), + os.path.join(current_dir, ".trae", "skills", "jianying-editor"), + os.path.join(current_dir, ".claude", "skills", "jianying-editor"), os.path.join(current_dir, "skills", "jianying-editor"), os.path.abspath(".agent/skills/jianying-editor"), + os.path.abspath(".trae/skills/jianying-editor"), + os.path.abspath(".claude/skills/jianying-editor"), os.path.dirname(current_dir) # 如果在 examples/ 目录下 -] if os.path.exists(os.path.join(p, "scripts", "jy_wrapper.py"))), None) +] if p and os.path.exists(os.path.join(p, "scripts", "jy_wrapper.py"))), None) if not skill_root: raise ImportError("Could not find jianying-editor skill root.") sys.path.insert(0, os.path.join(skill_root, "scripts")) diff --git a/docs/agent-playbook.md b/docs/agent-playbook.md index c84fb4d..e7a5984 100644 --- a/docs/agent-playbook.md +++ b/docs/agent-playbook.md @@ -2,6 +2,31 @@ Task routing matrix for reliable execution. +## 0) Quick Edit Runtime Template (Default for "来个剪辑") + +Use this fixed sequence: + +1. Environment check (minimal): + - verify Python works + - verify draft root exists +2. Asset resolution: + - use explicit local/cloud assets + - only call `asset_search.py` when user asks for style/effect lookup +3. Script assembly: + - generate one runnable script with deterministic APIs +4. Execute once: + - run script and capture success/failure output +5. Acceptance check: + - validate draft and track structure (see checklist below) + +### Acceptance Checklist (Mandatory) + +- Draft folder exists under JianYing drafts root +- `project.save()` completed successfully +- At least one `video` track segment exists +- If BGM exists, it must be on `audio` track (not `video`) +- If narration exists, subtitle segments should exist and be time-aligned + ## 1) Cloud Video + BGM Draft - Read: `rules/setup.md`, `rules/media.md`, `rules/audio-voice.md` diff --git a/docs/api.md b/docs/api.md index 9c62136..da971f2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,7 +9,8 @@ Bootstrap: ```python import os import sys -sys.path.insert(0, r"\\scripts") +skill_root = os.getenv("JY_SKILL_ROOT", r"") +sys.path.insert(0, os.path.join(skill_root, "scripts")) from jy_wrapper import JyProject ``` diff --git a/docs/minimal-command-sop.md b/docs/minimal-command-sop.md new file mode 100644 index 0000000..b6de199 --- /dev/null +++ b/docs/minimal-command-sop.md @@ -0,0 +1,66 @@ +# Minimal Command SOP + +Use this SOP for simple editing requests to minimize noisy exploration. + +## Goal + +- Produce one runnable script +- Run once +- Validate result with fixed checks + +## Step 1: Minimal Environment Check (2 commands max) + +```powershell +python --version +Test-Path "C:\Users\Administrator\AppData\Local\JianyingPro\User Data\Projects\com.lveditor.draft" +``` + +If draft path differs, set `JY_PROJECTS_ROOT` and continue. + +## Step 2: Asset Check (1 command) + +```powershell +Get-ChildItem .agent\skills\jianying-editor\assets -File +``` + +Do not recursively scan the whole workspace unless asset lookup fails. + +## Step 3: Script Generation + +- Create exactly one script in workspace root, e.g. `simple_edit.py`. +- Use deterministic APIs only: + - `JyProject(...)` + - `add_media_safe(...)` + - `add_cloud_media(...)` or `add_cloud_music(...)` + - `add_text_simple(...)` / `add_narrated_subtitles(...)` + - `save()` + +## Step 4: Single Execution + +```powershell +python simple_edit.py +``` + +If failed, patch script once based on the concrete error, then rerun once. + +## Step 5: Acceptance Validation (Mandatory) + +Verify all: + +- Draft directory exists +- Save success log appears +- At least one video segment exists +- BGM is on audio track (if used) +- Subtitles exist when narration exists + +## Error Handling Rules + +- `SegmentOverlap`: move clip start to track end or separate track. +- Missing asset: switch to known local asset or valid cloud ID. +- Import/path failure: set `JY_SKILL_ROOT`, rerun bootstrap. + +## Anti-Patterns (Avoid) + +- Repeated `Get-ChildItem -Recurse` over full workspace +- Temporary introspection files (`*_dir.txt`, `output.txt`) unless debugging is explicitly requested +- Multiple style/effect searches before first successful draft generation diff --git a/examples/simple_clip_demo.py b/examples/simple_clip_demo.py index 0663760..4fbb476 100644 --- a/examples/simple_clip_demo.py +++ b/examples/simple_clip_demo.py @@ -1,91 +1,64 @@ import os import sys -# Standard Boilerplate to load the skill wrapper -# ------------------------------------------------------------- -# 必选:这段代码负责自动定位 Skill 路径,请原样复制到你的脚本开头 -current_dir = os.path.dirname(os.path.abspath(__file__)) -# 扩展探测逻辑:覆盖 Antigravity(.agent), Trae(.trae), Claude(.claude), 以及通用(skills) -skill_candidates = [ - os.path.join(current_dir, ".agent", "skills", "jianying-editor"), - os.path.join(current_dir, ".trae", "skills", "jianying-editor"), - os.path.join(current_dir, ".claude", "skills", "jianying-editor"), - os.path.join(current_dir, "skills", "jianying-editor"), - os.path.join(current_dir, "jianying-editor-skill", ".agent", "skills", "jianying-editor"), - os.path.abspath(".agent/skills/jianying-editor"), - # Special case for examples folder (up one level) - os.path.join(os.path.dirname(current_dir)) -] -wrapper_path = None -for p in skill_candidates: - if os.path.exists(os.path.join(p, "scripts", "jy_wrapper.py")): - wrapper_path = os.path.join(p, "scripts") - break -if wrapper_path and wrapper_path not in sys.path: - sys.path.insert(0, wrapper_path) +def resolve_wrapper_path() -> str: + current_dir = os.path.dirname(os.path.abspath(__file__)) + env_root = os.getenv("JY_SKILL_ROOT", "").strip() + candidates = [ + env_root, + os.path.join(current_dir, ".agent", "skills", "jianying-editor"), + os.path.join(current_dir, ".trae", "skills", "jianying-editor"), + os.path.join(current_dir, ".claude", "skills", "jianying-editor"), + os.path.join(current_dir, "skills", "jianying-editor"), + os.path.abspath(".agent/skills/jianying-editor"), + os.path.abspath(".trae/skills/jianying-editor"), + os.path.abspath(".claude/skills/jianying-editor"), + os.path.dirname(current_dir), # examples/ under skill root + ] + for p in candidates: + if not p: + continue + scripts_dir = os.path.join(os.path.abspath(p), "scripts") + if os.path.exists(os.path.join(scripts_dir, "jy_wrapper.py")): + return scripts_dir + raise ImportError("Could not find jianying-editor/scripts/jy_wrapper.py") -try: - from jy_wrapper import JyProject - print(f"✅ Successfully loaded JyProject from: {wrapper_path}") # Added print for success -except ImportError: - # 这一步是为了在找不到路径时给出明确提示,方便调试 - print("❌ Critical Error: Could not load 'jy_wrapper'. Check skill paths.") - sys.exit(1) -# ------------------------------------------------------------- -# ============================================================================== -# 🎬 简单剪辑示例 (Simple Clip Demo) -# ============================================================================== +WRAPPER_PATH = resolve_wrapper_path() +if WRAPPER_PATH not in sys.path: + sys.path.insert(0, WRAPPER_PATH) -def main(): - # 1. 初始化项目 - # project_name: 剪映草稿的名字 - # overwrite=True: 如果项目已存在,允许覆盖(谨慎使用) +from jy_wrapper import JyProject + + +def main() -> None: project = JyProject(project_name="Hello_JianYing_V3", overwrite=True) - - # 2. 准备素材路径 (这里使用 Skill 自带的测试素材) - # wrapper_path是指向 scripts 目录的,它的上一级通常是 skill root - if 'wrapper_path' in globals() and wrapper_path: - skill_root = os.path.dirname(wrapper_path) - else: - # Fallback if wrapper_path somehow isn't set (shouldn't happen with new boilerplate) - skill_root = os.path.abspath(os.path.join(current_dir, "..")) + + skill_root = os.path.dirname(WRAPPER_PATH) assets_dir = os.path.join(skill_root, "assets") video_path = os.path.join(assets_dir, "video.mp4") bgm_path = os.path.join(assets_dir, "audio.mp3") - if not os.path.exists(video_path): - print(f"⚠️ Demo assets not found at {assets_dir}, using placeholders.") - # 如果你运行此脚本时没有这些文件,请替换为你本地的真实路径 + if not os.path.exists(video_path) or not os.path.exists(bgm_path): + print(f"Demo assets not found: {assets_dir}") return - # 3. 添加主视频轨道 - # add_media_safe 会自动识别文件类型 - print("📥 Importing Video...") - project.add_media_safe(video_path, start_time=0, duration="5s") + print("Importing video...") + project.add_media_safe(video_path, start_time="0s", duration="5s", track_name="VideoTrack") - # 4. 添加背景音乐 - # track_name="Audio": 指定放入音频轨道 - print("🎵 Adding Music...") - project.add_media_safe(bgm_path, start_time=0, duration="5s", track_name="Audio") + print("Adding bgm...") + project.add_media_safe(bgm_path, start_time="0s", duration="5s", track_name="AudioTrack") - # 5. 添加字幕 (带入场动画) - # transform_y: 垂直位置,-1.0 是底部,1.0 是顶部,0 是中间 - # anim_in: 入场动画。支持直接使用中文名称 (如 "复古打字机", "弹入", "向右滑动") - # 提示: 你可以使用 `python scripts/asset_search.py "打字"` 来查找可用的动画名 - print("📝 Adding Text with Animation...") - project.add_text_simple("Hello JianYing API!", start_time="1s", duration="3s", - transform_y=-0.7, color_rgb=(1, 1, 0), # 黄色字幕 - anim_in="复古打字机") + print("Adding text...") + # Keep text clips non-overlapping on the same text track to avoid SegmentOverlap. + project.add_text_simple("Hello JianYing API!", start_time="1s", duration="1.6s", anim_in="复古打字机") + project.add_text_simple("Simple Clip Demo", start_time="2.7s", duration="1.6s", anim_in="向右滑动") - - # 6. 保存项目 - # 这会生成草稿文件并自动刷新剪映首页列表 - print("💾 Saving Project...") + print("Saving project...") project.save() - - print("\n✨ Done! Open JianYing (剪映) and look for 'Hello_JianYing_V3'.") + print("Done. Open JianYing and find draft: Hello_JianYing_V3") + if __name__ == "__main__": main() diff --git a/rules/core.md b/rules/core.md index c558c49..fd8a14d 100644 --- a/rules/core.md +++ b/rules/core.md @@ -67,3 +67,23 @@ python /scripts/auto_exporter.py "ProjectName" "custom_output.mp4" - - **Draft Recognition**: The wrapper automatically handles `DraftFolder` structure. Do not manually manipulate `draft_content.json` unless you know exactly what you are doing. - **Exporting Requirements**: Auto-exporting only works on **Windows** with **Jianying v5.9 or lower**. It relies on `uiautomation` to interact with the UI. - **UI Refresh**: After the script runs, if Jianying is open, the user may need to exit and re-enter the draft to see changes. + +## Quick Edit Execution Template (Standard) + +For generic requests like "来个剪辑", execute in this order: + +1. Minimal environment checks (python + drafts root) +2. Resolve required assets (local first, cloud second) +3. Generate one deterministic edit script +4. Run script once and collect output +5. Perform acceptance checks and report concrete results + +## Acceptance Checks (Standard) + +After execution, verify: + +- Draft directory exists +- Save completed (`project.save()` success) +- At least one segment exists on a video track +- BGM (if used) is on audio track +- Narration/subtitle pairing exists when TTS was requested diff --git a/rules/setup.md b/rules/setup.md index 5751f07..bf5497e 100644 --- a/rules/setup.md +++ b/rules/setup.md @@ -8,6 +8,7 @@ metadata: # Environment Initialization Use a single reliable bootstrap path. Do not assume helper files outside this repo. +Always support multiple editor layouts and allow explicit override. ## Recommended Bootstrap (Mandatory) @@ -16,7 +17,9 @@ import os import sys current_dir = os.path.dirname(os.path.abspath(__file__)) +env_root = os.getenv("JY_SKILL_ROOT", "").strip() skill_candidates = [ + env_root, os.path.join(current_dir, ".agent", "skills", "jianying-editor"), os.path.join(current_dir, ".trae", "skills", "jianying-editor"), os.path.join(current_dir, ".claude", "skills", "jianying-editor"), @@ -26,13 +29,21 @@ skill_candidates = [ ] scripts_path = None +attempted = [] for p in skill_candidates: + if not p: + continue + p = os.path.abspath(p) + attempted.append(p) if os.path.exists(os.path.join(p, "scripts", "jy_wrapper.py")): scripts_path = os.path.join(p, "scripts") break if not scripts_path: - raise ImportError("Could not find jianying-editor/scripts/jy_wrapper.py") + raise ImportError( + "Could not find jianying-editor/scripts/jy_wrapper.py\\nTried:\\n- " + + "\\n- ".join(attempted) + ) if scripts_path not in sys.path: sys.path.insert(0, scripts_path) @@ -43,5 +54,6 @@ from jy_wrapper import JyProject ## Notes - Do not use `from jy import JyProject` unless your project explicitly provides `jy.py`. +- Prefer setting `JY_SKILL_ROOT` in mixed editor environments. - Put business scripts in the user workspace, not inside the skill repo. - Always call `project.save()` at the end. diff --git a/scripts/utils/env_setup.py b/scripts/utils/env_setup.py index 70afeec..905422a 100644 --- a/scripts/utils/env_setup.py +++ b/scripts/utils/env_setup.py @@ -1,5 +1,6 @@ import os import sys +from utils.skill_path import resolve_skill_root def setup_env(): """ @@ -16,27 +17,7 @@ def setup_env(): except Exception: start_dir = os.getcwd() - skill_root = None - - possible_roots = [ - start_dir, - os.path.join(start_dir, ".."), - os.path.join(start_dir, "..", ".."), - os.path.join(start_dir, ".agent", "skills", "jianying-editor"), - os.path.join(os.getcwd(), ".agent", "skills", "jianying-editor"), - os.path.join(os.getcwd(), "skills", "jianying-editor"), - ] - - for p in possible_roots: - p = os.path.abspath(p) - if os.path.exists(os.path.join(p, "scripts", "jy_wrapper.py")): - skill_root = p - break - - if not skill_root: - fallback_root = os.path.join(os.getcwd(), ".agent", "skills", "jianying-editor") - if os.path.exists(fallback_root): - skill_root = fallback_root + skill_root, _ = resolve_skill_root(start_dir) if skill_root: scripts_dir = os.path.join(skill_root, "scripts") diff --git a/scripts/utils/skill_path.py b/scripts/utils/skill_path.py new file mode 100644 index 0000000..c64a941 --- /dev/null +++ b/scripts/utils/skill_path.py @@ -0,0 +1,62 @@ +import os +from typing import List, Optional, Tuple + + +def _build_candidates(start_dir: str) -> List[str]: + """ + Build candidate skill roots for different editors/layouts. + """ + cwd = os.getcwd() + parents = [start_dir, os.path.join(start_dir, ".."), os.path.join(start_dir, "..", "..")] + editor_paths = [ + os.path.join(start_dir, ".agent", "skills", "jianying-editor"), + os.path.join(start_dir, ".trae", "skills", "jianying-editor"), + os.path.join(start_dir, ".claude", "skills", "jianying-editor"), + os.path.join(start_dir, "skills", "jianying-editor"), + os.path.join(cwd, ".agent", "skills", "jianying-editor"), + os.path.join(cwd, ".trae", "skills", "jianying-editor"), + os.path.join(cwd, ".claude", "skills", "jianying-editor"), + os.path.join(cwd, "skills", "jianying-editor"), + ] + return parents + editor_paths + + +def resolve_skill_root(start_dir: str) -> Tuple[Optional[str], List[str]]: + """ + Resolve skill root by environment variable first, then candidate probing. + Returns (resolved_root, attempted_paths). + """ + attempted: List[str] = [] + + env_root = os.getenv("JY_SKILL_ROOT", "").strip() + if env_root: + env_root = os.path.abspath(env_root) + attempted.append(env_root) + if os.path.exists(os.path.join(env_root, "scripts", "jy_wrapper.py")): + return env_root, attempted + + for p in _build_candidates(start_dir): + ap = os.path.abspath(p) + attempted.append(ap) + if os.path.exists(os.path.join(ap, "scripts", "jy_wrapper.py")): + return ap, attempted + + return None, attempted + + +def ensure_skill_scripts_on_path(start_dir: str) -> str: + """ + Resolve skill root and prepend its scripts path to sys.path. + Raises ImportError with tried paths when not found. + """ + import sys + + root, attempted = resolve_skill_root(start_dir) + if not root: + msg = "Could not find jianying-editor skill root.\nTried:\n- " + "\n- ".join(attempted) + raise ImportError(msg) + + scripts_dir = os.path.join(root, "scripts") + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + return root