diff --git a/.github/workflows/jianying-windows.yml b/.github/workflows/jianying-windows.yml new file mode 100644 index 00000000..8d48c2cb --- /dev/null +++ b/.github/workflows/jianying-windows.yml @@ -0,0 +1,69 @@ +name: jianying (windows-latest) + +on: + pull_request: + paths: + - 'plugins/antianqi/jianying/**' + - '.github/workflows/jianying-windows.yml' + - 'test/jianying.test.mjs' + push: + branches: [main] + paths: + - 'plugins/antianqi/jianying/**' + - '.github/workflows/jianying-windows.yml' + - 'test/jianying.test.mjs' + # Manual dispatch: lets the maintainer or the PR author run the same + # windows-latest job outside a PR. The fork-to-upstream PR cannot trigger + # Actions without explicit maintainer approval, and first-time-contributor + # protection is on, so this is the way to capture a real Windows run. + workflow_dispatch: + +# The repository-wide `validate` job runs on ubuntu-latest and checks the +# package shape (manifests, Skills, MCP transports, docs, placeholders, path +# safety). The `jianying` Plugin is Windows-only: its runtime needs a live +# Jianying instance, a local named pipe and a Windows toolchain, none of +# which exist on a GitHub-hosted Linux runner. +# +# This workflow is the scoped Windows counterpart, and it follows the rule +# recorded in .github/workflows/ci.yml: a non-tool-map Windows Plugin adds its +# own paths-filtered workflow that runs exactly one focused test file, rather +# than re-introducing a `validate-windows` job that scans the whole +# repository (that job was dropped in round-9 because it failed on files this +# PR does not own). +# +# The test below is static and cross-platform: it asserts the manifest shape, +# that every Skill path in the v0.4 manifest resolves AND that its frontmatter +# name equals its directory name (a mismatch is silently dropped by the +# runtime, with no diagnostic), that script invocations are anchored to +# ${PLUGIN_ROOT}, and that the Plugin ships no network client, no credential +# literal, no telemetry and no contributor machine path. +# +# The runtime-dependent parts (the verb self-tests, the end-to-end draft edit, +# the official renderer) need a real Windows desktop with Jianying 11.6 +# installed, so they are documented in the PR description as manual evidence +# rather than pretended into CI. + +permissions: + contents: read + +jobs: + jianying-windows: + name: jianying on windows-latest (package shape, Skill paths, no hardcoded paths) + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node (system) + run: | + node --version + npm --version + + - name: Run jianying focused test suite + run: | + cd '${{ github.workspace }}' + node --test test/jianying.test.mjs diff --git a/plugins/antianqi/jianying/.claude-plugin/plugin.json b/plugins/antianqi/jianying/.claude-plugin/plugin.json new file mode 100644 index 00000000..631dc3c3 --- /dev/null +++ b/plugins/antianqi/jianying/.claude-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "jianying", + "version": "0.2.0", + "description": "用逐像素复刻的剪映调色链给视频调色并出 10bit 成片(保留原音轨),附逐节点生效探针与画面级验收。10 个 MCP 动词覆盖草稿读写、调色、混音、渲染、验收、一条命令出成片,以及按预置路线编排多条动词(官方渲染器画面 + 我们的调色/混音 + ffmpeg 编码 + 带断言的验收)。", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "license": "Apache-2.0", + "keywords": [ + "mcode", + "jianying", + "video-editing", + "color-grading", + "10bit", + "mcp" + ], + "skills": [ + "./skills/jianying-cut/SKILL.md", + "./skills/jianying-draft-edit/SKILL.md", + "./skills/jianying-review/SKILL.md", + "./skills/jianying-talk-cut/SKILL.md", + "./skills/jianying-verify/SKILL.md", + "./skills/jianying-grade-chain/SKILL.md", + "./skills/jianying-pipeline/SKILL.md" + ], + "mcpServers": { + "jianying": { + "type": "stdio", + "command": "node", + "args": [ + "./mcp/server.mjs" + ], + "cwd": "./mcp" + } + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/EVIDENCE.md b/plugins/antianqi/jianying/EVIDENCE.md new file mode 100644 index 00000000..924f9524 --- /dev/null +++ b/plugins/antianqi/jianying/EVIDENCE.md @@ -0,0 +1,421 @@ +# 实测证据 + +> 这里的每个数字都是跑出来的,不是抄文档。 +> 复现方式写在每节末尾;完整证据全文在 JYAI 仓库 `docs/OFFICIAL-REALTIME-PATH.md`(§54–§77)。 + +## 1. 调色链:13/13 feature 逐像素对齐官方 shader + +| feature | 对齐口径 | 实测 | +|---|---|---| +| `curves`(曲线) | 恒等 LUT 与官方生成器比对 | **256/256 texel 字节完全一致** | +| `hsl`(中性) | 逐字节比对 | **逐字节相同** | +| `hsl_curves` | 逐点 | maxAbs **1 LSB** | +| 65³ 3D LUT | 与官方 LUT 生成器比对 | maxAbs **2 LSB**(建议 129³,同为 2 LSB 但更平滑) | +| 曲线 / 分级 / 色轮 / 日志轮 / 色彩校正 | 逐点 | maxAbs **1 LSB** | + +**为什么值得较这个真**:差 1 LSB 肉眼无感,差 8 LSB 就有色带。 +1 LSB ⇒ 调色结果可以当"官方结果"用,不需要人工微调。 + +## 1.5 ★ 官方渲染/预览那条路**自带一层有损投影** + +官方渲染器出的是 **PNG 序列(无损)→ h264 High / yuv420p / 8bit mp4**。 +实测同一批帧,PNG 原帧 vs 从它自己的 mp4 里抽出来的帧: + +| 帧 | MAD | maxAbs | 变化像素 | +|---|---|---|---| +| 0 | 1.971 | 157 | 77.9% | +| 20 | 2.199 | 159 | 79.1% | +| 39 | **8.339** | 70 | 95.7% | +| 64 | 7.416 | 66 | 95.2% | +| 90 | 0.627 | 38 | 34.5% | + +⇒ **剪映的预览/导出看到的那一层,本身就和它的数学差了 MAD 2~8、maxAbs 157、95% 的像素都变了。** + +**对照**(同一 0–255 标度): + +| | 误差 | 产物格式 | +|---|---|---| +| 我们的复刻 vs 官方 shader | **maxAbs ≤ 1**(曲线 LUT 256/256 字节一致) | **hevc Main 10 / yuv420p10le** | +| 官方渲染 PNG → 它的 mp4 | MAD 2~8 · maxAbs 157 | h264 High / yuv420p / **8bit** | + +⇒ 所以"打开剪映核对"核对的是一个**自己都不准的投影**; +⇒ 而我们算的是**剪映的数学本身**,且中间不叠那层 8bit 4:2:0 损失 +⇒ **在对话框里直接认证**比"导出预览再肉眼比"更准,也更快。 + +## 2. 10bit / 16F 出片 + +| 项 | 实测 | +|---|---| +| RGBA16F FBO | **COMPLETE** | +| 渲染误差(16F) | **0.000187** | +| 渲染误差(8bit) | 0.001751 ⇒ 16F 好 **9.4×** | +| banding 台阶 | 1 / 4.4 | + +**诚实结论**:位深收益在**4:2:0 交付**时会被色度子采样淹没。 +⇒ 能吃满位深的是**中间格式 / 母版**(ProRes 422 HQ、16bit WAV、FFV1),不是最终 mp4。 + +## 3. `clear`(清晰度):官方 8 级金字塔的接线自证 + +官方结构全解:**6 支 frag · 23 个 RT · 8 级金字塔**(1280×720 → 10×5), +尺寸链从 `.rt` 二进制逐字节 diff 解出。 + +**接线代数自证**(不靠肉眼): + +| 条件 | 自洽性 \|C_0 − G_0\| | +|---|---| +| 正确接线(fact=0 / 无量化 / 不 clamp) | **9.737e-19** | +| 注入 wrong_lap | 5.7e-2 | +| 注入 wrong_add | 3.6e-2 | +| 注入 no_top | 6.0e-4 | + +⇒ 判据分辨力 **5.7e+16 倍**。 + +**官方参考帧**(造 `type:'clear'` 的 intensity 型 effect 节点草稿 → 官方渲染): + +| value | 官方行为 | +|---|---| +| 0 | **空转**(逐像素相同)—— 官方默认就是这个 | +| 1 | 生效,MAD **0.004523**(40 640 px 变化,unsharp mask 签名) | +| 0.5 | 线性(v0.5 / v1.0 = **0.4986**) | + +## 4. 官方 27 个行为操作的参数契约(实测,非抄文档) + +方法:直接用官方 `app-server.mjs` 的 `gk0` 白名单 + 逐个参数名实探 +(用真 segmentId / 真时间戳 / 真素材路径,看官方报错是否变化)。 + +| op | 必填参数(实测) | 备注 | +|---|---|---| +| `video.add` | **`path`** | `source` / `sourcePath` / `filePath` 都不认 | +| `audio.add` | **`audioPath`** | ★ 不是 `source` / `path` | +| `text.add` | **`content`** | `text` 不认 | +| `track.mute` / `track.delete` | **`trackId`** | | +| `segment.setTimeRange` | **`segmentId`** + 时间 | `startTimeUs` / `startUs` + `durationUs` 均可 | +| `segment.delete` | **`segmentId`** | | +| `segment.move` | **`segmentId`** + **需要 snapshot** | `segment_move_snapshot_required` | +| `segment.split` | **`splitTime`** | ★ 不是 `splitTimeUs` | +| `track.add` | 仍未探到 | 报 `track_add_params_required`,候选全不对 | + +**★ 官方行为操作是带命名空间的**(`segment.move` 不是 `move`)—— +历史上"白名单 0/150"就是命名错造成的。 + +**★ 剪"中间一段"的正确链** + +```text +① segment.split {segmentId, splitTime: t1} → 切成两段(后半段拿新 id) +② segment.split {segmentId: 前半id, splitTime: t0} → 前半再切两半 +③ segment.delete {segmentId: 中间段id} → 时间线缩短 (t1 - t0) +``` + +实测:时间线 **9 033 333 → 8 033 333 µs**(正好剪掉 1 秒)。 +**★ split 会让后面的段整体后移 ⇒ 多个区间必须从后往前处理。** + +## 5. 判据纪律(本插件真正的护城河) + +### 5.1 官方渲染器**不是确定性的** + +同一份草稿**渲染两次**,帧就已经不一样: + +| 样本 | 不同的帧 | 最差 MAD | +|---|---|---| +| 抓帧容差 60ms | 14/108 | **48.19** | +| 抓帧容差 20ms(收紧后) | 17/108 | **123.55** | + +⇒ 噪声底比任何"改动可见"阈值(0.5)**高两个数量级** ⇒ 裸像素差不能证明"改动可见"。 + +**排除过的假设**:收紧容差后 108/108 全部精确命中、噪声依旧 ⇒ 不是容差问题; +提交前 vs 提交后 MAD 17.41(比同态噪声还小)⇒ 不是素材路径重定位; +渲染前后草稿目录 sha 树 64 文件 0 变化 ⇒ 渲染是只读的。 + +### 5.2 主判据换成**语义** + +改草稿链的"改动生效"证据 = **落盘草稿的逐段字段差**: + +| 场景 | 证据 | +|---|---| +| 调音量 | 真草稿 `volume=0.99` → 落盘副本 `volume=0.2`(其余 3 段不变) | + +⇒ **完全不依赖像素**,确定性、可复跑。 + +### 5.3 已踩过的反面教材(都写进了 `references/pitfalls.md`) + +| # | 坑 | 后果 | +|---|---|---| +| 1 | 单帧抽样当判据 | 既会假 FAIL 也会假 PASS | +| 2 | 两份验收并发写同一目录 | 证据互相污染 | +| 3 | 削波用**绝对值**口径 | 白底图 clip=36% ⇒ 误判 FAIL | +| 4 | 读错状态源(动词之间不共享状态) | "各自都成功,链路却是错的" | +| 5 | 基线借用**别的跑**的旧目录 | 比的是两次不同的改动 ⇒ 假绿 | +| 6 | 用字符串收集 stdout | 二进制被毁 ⇒ 假报"0 差异" | +| 7 | 读指定草稿缺 `--root` | **静默返回空**,比报错更难发现 | + +## 6. 自检(全部带负向注入) + +| 自检 | 结果 | +|---|---| +| `test/jianying.test.mjs` | **11 / 11** | +| `scripts/selftest-mcp.mjs` | **58 / 58**(含编排器两段式 4 条 + 新路线 2 条) | +| `scripts/selftest-draft-edit.mjs` | **17 / 17** | +| `scripts/selftest-talk-cut.mjs` | **11 / 11**(带草稿;无草稿时落稿链显式 SKIP ⇒ 7 项) | +| `scripts/check-env.mjs` | **17 OK / 0 MISS** | +| 上游官方校验器 | `OK plugin antianqi/jianying` · **30 插件全绿** | + +**负向注入自检 7/7**(每个判据都必须能因正确原因失败): + +| 注入 | 期望失败 | 实际 | +|---|---|---| +| frontmatter `name` != 目录名 | 失败 | `frontmatter name must equal its directory name` | +| `description` 超 1024 字符 | 失败 | `description is 1285 chars (limit 1024)` | +| 代码里写死盘符路径 | 失败 | `must not hardcode a drive letter path` | +| 代码里出现 `fetch(` | 失败 | `must not make HTTP requests` | +| README 删掉"预期结果" | 失败 | `README must state the expected result` | +| README 删掉"无遥测"披露 | 失败 | `README must disclose telemetry behaviour` | +| manifest `skills[]` 指向缺失文件 | 失败 | Skill 路径列表 deep-equal 不匹配 | + +## 6.5 ★★ 编排器的两段式(人工确认点)—— 实测证据(2026-10-24) + +> 路线 `talk-cut-then-render` 里那一步(产出"保留/删除"区间表)标了 +> `"confirm": true` + `"artifact": "$planPath"` ⇒ 编排器跑完那一步就**停下来**。 + +| 断言 | 期望 | 实测 | +|---|---|---| +| 第一段 | 停在关卡,后面 3 步**一步都没跑** | `verdict=AWAIT_CONFIRM` · `gate.step=3` · `gate.pendingSteps=3` | +| 关卡产物 | 存在 + 有 sha | `artifact=<…>/talk-cut-plan.json` · `artifactSha` 非空 · `artifactPreview.stats` 有值 | +| **不确认就续跑** | **拒绝执行** | `verdict=REFUSED` · 自检断言"被拒那次 `talk-cut-apply.mjs` **根本没被执行**" | +| 假 token | 明确报错(不许默默从头跑) | `找不到流水线状态` | +| 确认后续跑 | 走完 | `verdict=PASS` · apply 自证 **已验证剪掉 2/2 个区间** | +| 真草稿 | 全程零改动 | 落盘前后 sha 相同(`realDraftUnchanged=true`) | + +**apply 的判据(形态无关 + 有区分力)**: + +| 区间 | 这一轨时长总量 | 结果 | +|---|---|---| +| `2200000..2600000 us`(400 000 us) | 3 000 000 → 2 600 000 | 正好少 400 000 ✅ | +| `200000..900000 us`(700 000 us) | 2 600 000 → 1 900 000 | 正好少 700 000 ✅ | + +阴性对照:演练模式(没 `--apply`)下同一条判据**必然不成立**(脚本当场打印 +"阴性对照:演练没动草稿 ⇒ 判据现在【不成立】")⇒ 判据能区分"剪了"和"没剪"。 + +## 6.6 ★★★ 这一轮挖出来的两个真缺陷(都是"官方说改了不算数"的变种) + +| # | 症状 | 根因 | 修法 | +|---|---|---|---| +| 1 | `draft_read {sessionId}` 读出来的**不是刚开的那份草稿**(段数/时长/id 全不一样);最坑的一次读到的是**两天前**另一个会话留下的草稿 | **剪映的 session id 会回收复用**(进程重启后从 1 重新数)⇒ 状态目录里还留着上一次的 `snapshot-.json`,而 `draft_read` 优先读快照 ⇒ 新会话"继承"了旧草稿 | `draft_open` 开新会话时**先删掉这个 id 的旧快照**;另加一道:会话记下草稿 id,读到的 id 对不上就点名报错 | +| 2 | `draft_patch` 报 `effectVerified=false, changes=0`,可段**确实**被切成了两半 | 生效探针的基线是**磁盘上的加密草稿** ⇒ `readDraftJson` 解析不出来 ⇒ 返回 null ⇒ 探针以为"改前不存在" ⇒ 段数变化算不出来 | 基线读不出来就**走官方管道解密**(`readDraftForDiff`)⇒ 探针不再假阴性 | + +⇒ 通用教训:**判据的基线读不出来时,要换一条路读,不能当成"没有基线"**; +**任何"按 id 缓存"的东西,都必须考虑 id 会回收**。 + +## 6.7 ★★ 判据的"无法判定"必须与"没变"分开(这一轮修的假绿) + +- 段级 diff 的 `fields` **只装【变了】的字段** ⇒ "没变"与"根本没法比"在 diff 里长得一样 + ⇒ `expect: {segmentFieldUnchanged: X}` 会被**静默满足**(假绿)。 +- 现在有 `fieldComparability(before, after, field)`:数"两边都有的段"与"只有一边有的段", + 字段不可比时**明确报"无法判定"**并判 FAIL(不装作"没变")。 +- 修完后 `selftest-draft-edit.mjs` 的负向注入 ⑦b 第一次**因正确原因**失败: + `不该变的 "volume" 字段却变了`(不是"无法判定")⇒ 判据有区分力。 +- 同理,⑦ 的"改前"基线改成从**会话快照**取(暖机 patch 先把快照变成展开形态)⇒ + 改前/改后**同形态** ⇒ 逐段字段差才可比。 + +## 6.8 ★ 编排器的另外两条路线(2026-10-24 续) + +| 路线 | 步骤链 | 实测 | +|---|---|---| +| `subtitle-then-render` | `draft_open → draft_read → add-subtitles.mjs → draft_commit → make_film` | **PASS** · 字幕脚本自证 **时码吻合 2/2**(±1 帧 = 33333 µs)· 成片 3.000000 s | +| `render-and-mix` | `draft_open → make_film → audio_mix` | **PASS** · 双流齐备 **-14.00 LUFS** · `mixed.mp4` 实测 avg = **-14.28 LUFS** | + +★ `render-and-mix` 用的是新加的 **`produces`** 机制:步骤声明 +`"produces": {"$film": "film.mp4"}` ⇒ 编排器把 `/film.mp4` 交给下一步 +(否则"混音"这一步拿不到画面)。 + +★ 跑在**没有音轨**的草稿上时,这条路线 **FAIL** 并说明停在哪一步 +(官方回 `nothing to mix`)⇒ 自检按环境分两种断言: +有音轨的草稿 ⇒ 断言成功;没有 ⇒ **断言它诚实失败**(不许"没音轨也照样出片成功")。 + +★ 顺带修掉一个盲区:`run-pipeline.schema.json` 的 `profile` **枚举**与 +`pipeline-profiles.json` **没有对账** ⇒ 加了新路线却忘了同步 ⇒ 路线存在却调不动 +(`不在允许集合`)。现在机械审计 7k 做**双向对账**,负向注入 ㉜ ⇒ 审计自检 **33 / 33**。 + +★ 顺带修掉一个自相矛盾:意图分类器的权重函数注释写"长词更具体 ⇒ 权重更高", +实现却是"3 字以上一律 3 分" ⇒ 命中"加字幕再出片"(6 字) 反而输给命中"加字幕"+"字幕"的 +通用 skill 条目。改成**随长度增长**后,测试句 33 句 **top-1 100% / top-3 100%**。 + +## 6.9 ★ 单版审片("审一下这版")与 `undetermined`(2026-10-24 续) + +| 维度 | 单版能判什么 | 为什么 | +|---|---|---| +| `visibility` | **判不了**(`skipped`)但**给出噪声底** | 没有改前就没有"改动"可比 | +| `picture` | 只给**绝对值**、**不判失败** | 片子里本来就有的纯白/纯黑不算缺陷 ⇒ 绝对值不能当判据 | +| `audio` | **能判** | 这一版的 LUFS / true peak(绝对目标 −14 LUFS) | +| `timeline` | 只给这一版的**结构** | 段数 / 轨道 / 时长 | +| `draft_integrity` | **能判** | 真草稿的 sha | + +实测(`review-only` 路线,夹具 `JYAI能力批测-带音轨夹具`): + +| 项 | 实测 | +|---|---| +| 噪声底 | 同态重渲 MAD = **0.0000206**(另一轮 0)⇒ **噪声底本身也非确定 ⇒ 每次都要量** | +| 画面绝对值 | `clipRatio = 0.00724` · verdict = **"参考(未判定)"** | +| 响度 | **integratedLufs = −14** ✓ | +| 时间线 | 段数 3 · `single: true` | +| 结论 | PASS + **`undetermined: [visibility]`** + `verdictNote`("这不是完整通过") | + +★ **"PASS" 不等于"全查过了"**:没判的维度现在进 `undetermined` 并附 `verdictNote` +⇒ 未测的维度要写成"待确认",不能写成"正常"(官方 `editing-review` 的纪律)。 + +★ 自检当场又抓出第二层:**查了但测不出来**(纯画面片没有音轨 ⇒ `ebur128` 量不出 LUFS ⇒ +`integratedLufs = null`)旧行为是**静默 null**(看着像"响度正常")⇒ +现在也进 `undetermined`(新增 `undeterminedReason`)⇒ 自检的断言是 +"响度**要么**达标(|LUFS+14| ≤ 0.5)**要么**出现在 undetermined 里"。 + +★ 顺带修了一个**报告层缺陷**:编排器的步骤摘要只留白名单字段 ⇒ `verify_diff` 的结论 +在 `checks` 里被丢掉 ⇒ 路线"跑成了却没产出它该产出的东西"。现在 `verify_diff` 走 +**专用紧凑摘要**(保留关键数字,丢掉逐帧明细)。 + +## 6.10 ★ 两版对照审片(`review-compare`)+ 一个"判据写反了"的 bug + +一次对照同时回答:画面变没变 · 画质新增削波 · 响度两版各是多少 · 时间线改了什么 · 真草稿有没有被误改。 + +实测(改前 = 纯视频探针,改后 = 带音轨夹具): + +| 项 | 实测 | +|---|---| +| 画面 | **FAIL(改动在画面上不可见)** ⇒ 两版只差音轨 ⇒ **判据不是恒真** ✓ | +| 噪声底 | MAD = 0.0000584 / 0.0000206(两次跑不同 ⇒ 噪声底本身非确定) | +| 画质 | `clipDelta = 0` | +| 响度 | 改前 **−14 LUFS**(truePeak −11.3 dBFS)· 改后 **−14 LUFS**(truePeak −13 dBFS) | +| 时间线 | 段数 **Δ = 1** | + +★ 这个 FAIL 是**正确的**(画面本就没变)⇒ 判据判出"不可见"反而是它可信的证据。 + +★★ 顺带抓出一个**判据写反了**的 bug:响度检查写成 `Math.abs(lufs) > 0.5` +⇒ 比较的是**响度值本身** ⇒ **任何正常归一到 −14 LUFS 的片子都会被说成"未归一"** +(报告里 `-14` 与"未归一"同时出现,自相矛盾)。修正为判偏离量 `|lufs − (−14)| > 0.5`。 +⇒ ★ 这类 bug **不会让自检变红**(只多一句 note)⇒ **只能靠人读报告发现**。 + +★ 编排器新增 **`setVars`**(步骤之后求值,抓这一步的产出存成别名) +⇒ 一份路线里开两份草稿时 `$dir` / `$sessionId` 不再互相覆盖; +审计守卫:`setVars`/`produces` 的变量必须声明、`produces` 路径不许 `..` 或绝对路径。 + +## 6.11 ★ 第 9 条路线:曲线类调色(`grade_apply` 终于被路线覆盖) + +`grade_apply` 是**曲线 / HSL / 色轮唯一走得通的路**(`make_film --grade` 只能做强度型) +⇒ 它之前没被任何路线覆盖 ⇒ **最需要调色链的需求,在编排器里根本调不出来**。 + +实测(spec = `adjustColor` + `logWheel`): + +| 项 | 实测 | +|---|---| +| 逐节点探针 | `adjustColor = 0.0136` · `logWheel = 0.0124` ⇒ 每个节点单独打开画面都变了 | +| 调色改变量 | `gradeChangeMad = 0.0260` | +| 产物 | `graded/graded10.mp4`(10bit 成片)· `graded/graded_lut.mp4` · `graded/grade-side-by-side.png` | + +⇒ **10 个动词至此全部被路线覆盖**(`run_pipeline` 之外的 9 个都在路线里)。 + +★ 编排器第三种"往后传"的方式:**`capture`**(从**响应**里取值,例:调色成片的确切文件名 +只有响应知道;响应里没这个字段 ⇒ 明确报错)。至此三种机制各管一段: + +| 机制 | 时机 | 干什么 | +|---|---|---| +| `produces` | 步骤**之前** | 声明产物**路径**(相对 `$outDir`) | +| `capture` | 步骤**之后** | 从**响应**里取值 | +| `setVars` | 步骤**之后** | 给变量起别名 / 派生模板 | + +★ 报告层再补一处:`grade_apply` 的探针与 `verify_diff` 的结论都不在摘要白名单上 +⇒ 路线"跑成了,报告里却看不到调色有没有生效" ⇒ 现在两者各走**专用紧凑摘要**; +报告新增 **`vars`** 字段 ⇒ 这条路线最终产出了什么一眼可见。 + +★★ 同一个坑栽了两次:JSON 里写 `+` 字符串拼接(JSON 不支持)⇒ 审计**崩栈**。 +⇒ 修法:`note` 写成单个字符串;**审计遇到坏 JSON 报成一条发现而不是崩栈** +(17 处解析统一走 `readJson()`)⇒ 审计工具不能因为被审对象写错就失能。 + +## 6.12 ★★ "绝不硬猜"这条纪律,终于有人守了 + +`classify-intent.mjs` 一直写着"绝不硬猜:没命中就明说没有明确线索",代码里也有那个分支 —— +但**从来没有任何测试量过它** ⇒ 谁往关键词表里加一句"看看""弄一下",它就开始硬猜,而**没有任何东西会红**。 + +⇒ 新增 **`references/intent-refusal-set.json`**(18 句没有明确意图的话)⇒ 自检断言 +**一句都不许被判成某条链**(硬猜数必须为 0)。 + +⇒ 顺带把分类器从"给建议"升级成"**给判决**"(`--decide`),两种拒答: + +| 结果 | 条件 | 含义 | +|---|---|---| +| **判决** | 最高分 ≥ 2 **且** 与次优**不同目标**的分差 ≥ 1 | 照它走(依据:命中词 / 分数 / 分差) | +| 拒答·没线索 | 最高分 < 2 | 根本没线索 ⇒ 问用户要具体诉求 | +| 拒答·分不清 | 两个**不同目标**分差 < 1 | 有线索但分不清 ⇒ 两个都摆给用户 | + +★ **分差只跟"不同目标"比**:同一个 skill 的两条条目并列("电影感"与"导个片调个色"都指向 +jianying-cut)⇒ 那是同一个答案,不是竞争者 ⇒ 不该因此拒答。 + +实测:测试句 36 句 **top-1 100% / top-3 100%** · 拒答集 18 句 **硬猜 0** · +判决 **36/36 给出且全对** · 明确句被拒 0 · 模糊句被硬判 0。 + +⇒ 审计新增 **7o**(拒答集必须存在且 ≥10 句 / 不许与主测试集重叠 / 拒答句里不许藏路线 id), +负向注入 **㉝** ⇒ 审计自检 **34 / 34**。 + +## 6.13 ★★ 判决带出"接下来要什么"(参数 + 该问用户什么) + +每条路线现在声明结构化 `inputs`(`name` / `kind` / `desc` / `required` / `from`∈{user, caller, derived, intent})。 + +| 用户说 | 判决 | 带出 | +|---|---|---| +| 「调暖一点」 | skill `jianying-cut` | **参数直接带**:`spec = {"adjustColor":{"temperature":0.15}}` | +| 「把口播剪了再出成片」 | route `talk-cut-then-render` | **要问用户**:`$draft`、`$srt` | +| 「曲线调完再出片」 | route `grade-chain-then-render` | **要问用户**:`$draft`、`$grade`(曲线形状猜不出来) | +| 「给这版审一遍」 | route `review-only` | **要问用户**:`$draft` | + +★ 缺参改成**开跑前预检、一次性点名**(以前是跑到第 3 步才说"缺 `$srt`"): + +```text +路线 "talk-cut-then-render" 还缺 2 个必需输入: + · $srt(转写稿(SRT))—— ★ 必须由用户提供;★ 官方 ASR 拿不到 ⇒ 必须由用户提供,不许自己编 + · $outDir(成片输出目录)—— 由调用方给 +⇒ 补齐后再跑;缺什么就问什么,不要拿默认值糊过去。 +``` + +⇒ 审计新增 **7p**(每条路线必须有 `inputs` / 输入名必须是已声明变量 / `from` 只能是那四个值 / +**必需输入必须在步骤里真的被用到**)。 + +## 6.14 ★★★ 排障:把锅指对(而不是说"剪映没跑") + +自检突然全红,报 `could not resolve the agent host parent pid (is JianyingPro running?)` +—— 可**剪映明明在跑**(9 个进程)。 + +**真相**:坏的是 **WMI 的提供程序主机**(`WmiPrvSE.exe`,从 9-29 起累计烧掉 **704178 秒 CPU** +≈ 8 天,已跑飞)⇒ `Get-CimInstance` / `tasklist` 全部 **ETIMEDOUT** ⇒ +桥拿不到 VE runtime 的管道 pid ⇒ 整条链死在那儿。 + +⇒ 原报错**把锅指错了**("我依赖的查询卡死了" 被说成 "剪映没跑")。修法: + +| 修法 | 内容 | +|---|---| +| 超时 | 20 秒 → **6 秒** | +| 诊断 | 分开说两种情况;剪映在跑但找不到 VE runtime ⇒ 明说"**这不是插件的问题**,在剪映里打开一个草稿会把它拉起来" | +| 兜底 | 枚举命名管道(管道名带 pid)—— ★ 实测 **VE 的管道列不出来** ⇒ 只能当兜底 | +| 机器侧 | 杀掉跑飞的 `WmiPrvSE.exe`(服务自动重拉)⇒ CIM 查询 **20 秒超时 → 550 毫秒** | + +★ 顺带:剪映重启后 **session 计数从 2 重来** ⇒ §114 修的"回收 session id 带旧快照"隐患**真实复现**了一次 +⇒ 实测 `draft_read` 读到的是**正确的草稿**(duration 3000000 · 2 段),不是旧会话的残留 ⇒ 修复有效。 + + + + + + +## 7. 复现 + +```bash +# 仓库自证(不需要剪映、不需要 Windows) +node --test test/jianying.test.mjs + +# 环境自检(需要 ffmpeg / python / 剪映缓存) +node scripts/check-env.mjs + +# 三个端到端自检(需要活的剪映 11.6 + 一个真实草稿) +$env:JYAI_TEST_DRAFT = "<草稿库>\<草稿名>" +node scripts/selftest-mcp.mjs +node scripts/selftest-draft-edit.mjs +node scripts/selftest-talk-cut.mjs +``` + +未设 `JYAI_TEST_DRAFT` 时自检会**明确打印 SKIP 并退出 0**(不伪装 PASS)。 diff --git a/plugins/antianqi/jianying/LICENSE b/plugins/antianqi/jianying/LICENSE new file mode 100644 index 00000000..ec5fe20c --- /dev/null +++ b/plugins/antianqi/jianying/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/jianying/README.md b/plugins/antianqi/jianying/README.md new file mode 100644 index 00000000..67410951 --- /dev/null +++ b/plugins/antianqi/jianying/README.md @@ -0,0 +1,183 @@ +# jianying + +> 用**逐像素复刻的剪映调色链**给视频调色,出 **10bit / 保留原音轨**的成片 —— 不需要剪映 GUI。 + +> **平台:仅 Windows。** 依赖本机的剪映 11.6 运行时与一个**活的**剪映进程(草稿读写与官方渲染都走剪映的 +> 本机命名管道)。调色与出片本身不需要打开剪映界面,但草稿相关动词需要剪映在运行。 + +## 解决什么问题 + +剪映的调色(亮度/对比/饱和/色温/分色/对数轮/曲线/HSL 曲线/锐化/暗角…)只有官方 app 能算。 +想把调色纳入 AI 自动化流水线(改参数 → 出片 → 再改),有两条路: + +1. **官方导出器** —— 官方 CLI 侧被 `−100007`(会话模式不支持)与 `−100015`(未授权宿主进程)挡死, + 且 CLI 构建比 app 早 5 天 ⇒ 命令树注册表错位。**这是官方侧的问题,我们不绕。** +2. **自己复刻**(本插件)—— 把官方的调色数学逐 feature 复刻出来(13/13 逐像素对齐,误差 ≤ 1 LSB), + 自己出片。 + +⇒ 本插件走第 2 条:**MCP 提供 10 个原子动词**(草稿读写 / 调色 / 出片 / 混音 / 渲染 / 验收), +**Skill 提供 7 条"有自己中间思路 + 门禁"的剪辑链路**(含一条跨链流水线:一件事做完时按它串联)。 + +## 试一下 + +**示例提示词**(可直接粘给 MiniMax Code): + +```text +给这个视频调色出片,风格用暖一点的,输出到 out\cut\demo.mp4 +``` + +**预期结果**: + +1. 插件先跑 `check-env` 环境自检,缺依赖会直接报缺哪一个。 +2. `grade_apply` 按 `spec` 出片,产出 `graded10.mp4`(逐点直算,推荐)与 `graded_lut.mp4`(65³ LUT 形态)。 +3. 同一个动词返回**逐节点生效探针**:`adjustColor` / `logWheel` / … 每一项的 MAD 都必须非零, + 任何一个为 0 ⇒ 动词**直接判失败**(而不是"链看着跑通、实际少一个节点")。 +4. 返回全链改变量 `grade_change_mad`(实测 > 0)与产物路径。 +5. 画面验收用 `verify_diff` 对改前/改后帧做**全帧最大**像素差;注意官方渲染器本身有噪声底, + 像素结论要带 `noiseDir` 校准(见 `skills/jianying-verify/SKILL.md`)。 + +**示例提示词二(给剪映草稿出成片)**: + +```text +把这个剪映草稿出成片,输出到 out\cut\demo.mp4 +``` + +⇒ 走 `make_film`:**官方渲染器出画面** → 可选我们的 3D LUT(曲线类调色)→ **我们的混音链** +(音量 / 淡入淡出 / 降噪 / 响度归一)→ ffmpeg 编码 → **带断言的验收** +(双流齐备 + 时长对齐官方时间线 ±1 帧 + 响度偏离 ≤1 LUFS ⇒ 否则判失败)。 +★ **曲线参数由我们自己的接口给**(`grade` 字段),不需要你在剪映里先存一份带曲线的草稿。 + +也可以直接调动词: + +```bash +node scripts/run-verb.mjs --list +node scripts/run-verb.mjs grade_apply '{"src":"<源视频.mp4>","outDir":"<输出目录>","spec":{"adjustColor":{"brightness":0.1,"temperature":0.15}}}' +``` + +## 10 个动词 + +| 动词 | 作用 | +|---|---| +| `draft_open` | 打开草稿(默认只读副本) | +| `draft_read` | 读草稿语义结构(时间线/片段/素材) | +| `draft_patch` | 语义级修改(**官方行为操作全集:18 域 / 199 个,桥全部放行**;含加字幕、调色写回入口) | +| `draft_commit` | 落盘(自动素材路径重定位 + 真草稿 sha 断言) | +| `render_frames` | 用官方渲染器出帧/mp4 | +| `grade_apply` | 调色(10 节点链)+ 出 10bit 成片(**自带逐节点生效探针**) | +| `audio_mix` | 混音 + 响度归一到 −14 LUFS | +| `verify_diff` | 多维度验收:画面可见性 / 画质 / 响度 / 时间线 / 草稿完整性 | +| **`make_film`** | **给剪映草稿一条命令出成片**(官方渲染器出画面 + 我们的调色/混音/编码 + 带断言的验收) | +| **`run_pipeline`** | **按预置路线编排多条动词**(开副本 → 改草稿 → 落盘 → 语义验收 → 出成片;**9 条路线**,定义在 `references/pipeline-profiles.json`)★ 支持**脚本步骤**、**产物传递**(`produces` 声明路径 / `capture` 从响应取值)、**变量改名**(`setVars`,一份路线里开两份草稿)与**人工确认点**(跑一半停下来等人确认,确认后带 token 续跑) | + +## 7 个 Skill + +| Skill | 干什么 | +|---|---| +| `jianying-cut` | 调色出片链路(风格预设 + 门禁) | +| `jianying-draft-edit` | 改草稿链路(官方操作全集 + 落盘 + 生效验证) | +| `jianying-review` | 审片验收(多维度对照 + 最小返修范围) | +| `jianying-talk-cut` | 剪口播(SRT 规划器 + 水词/停顿/重复判定 + 落刀) | +| `jianying-verify`(库) | 验收判据纪律(**仅被引用时用**) | +| `jianying-grade-chain`(库) | 调色链 API 语义(10 节点 × 参数)(**仅被引用时用**) | +| `jianying-pipeline` | **跨链流水线**:一句话做完一件事(改完→审→出片),一份副本走到底 | + +## 精度 + +| 路径 | 误差 | +|---|---| +| 逐点直算(默认) | maxAbs **1 LSB** | +| 65³ 3D LUT | maxAbs **2 LSB** | +| 129³ 3D LUT | maxAbs **2 LSB**(更小) | + +曲线恒等情形与官方 LUT 生成器 **256/256 texel 字节完全一致**。 + +## 结构 + +```text +plugin-root/ +├── plugin.json # portable Agent Plugins 1.0(mcode 0.3.x) +├── mcp.json # MCP 声明(0.3.x) +├── .claude-plugin/plugin.json # v0.4.0+ manifest(skills[] + mcpServers) +├── mcp/ +│ ├── server.mjs # 10 动词 stdio MCP server(0 npm 依赖) +│ └── tools/.schema.json# 每个动词的入参 schema(硬校验) +├── skills/ +│ ├── jianying-cut/SKILL.md +│ ├── jianying-draft-edit/SKILL.md +│ ├── jianying-review/SKILL.md +│ ├── jianying-talk-cut/SKILL.md +│ ├── jianying-verify/SKILL.md +│ ├── jianying-grade-chain/SKILL.md +│ └── jianying-pipeline/SKILL.md +├── references/ # 共享知识(按需引用) +│ ├── chain-api.md # 链 API 语义(10 节点 × 参数) +│ ├── official-ops.md # 官方 op 全集(18 域 199 个,逐个打桥的结果) +│ ├── verify-discipline.md # 判据纪律(视野 / 噪声底 / 注入自检) +│ ├── pitfalls.md # 踩坑清单 +│ ├── capability-dict.json # 能力字典(10 动词 / 3 类元素 / 3 类节点 / 6 类判据,机器可读) +│ ├── intent-routes.json # 意图路由表(一句人话 ⇒ 该走哪条链/路线) +│ ├── intent-testset.json # 意图路由的带标注测试集(准确率可量化) +│ └── intent-refusal-set.json # 拒答集(没有明确意图的话 ⇒ 必须拒答,不许硬猜) +└── scripts/ + ├── run-verb.mjs # 插件自带的 MCP 客户端(让 skill 调动词) + ├── classify-intent.mjs # 意图分类器(一句人话 ⇒ 候选链/路线 + 命中词;--decide ⇒ 判决或明确拒答) + ├── selftest-intent.mjs # 意图路由准确率自检(带标注测试集 ⇒ 低于阈值就红) + ├── check-env.mjs # 环境自检 + ├── selftest-mcp.mjs # MCP 动词自检(62 项断言,含 MCP 层冒烟 / 参数覆盖 / 意图路由准确率 / 编排器确认点、缺参预检与新路线) + ├── selftest-draft-edit.mjs # 改草稿链路端到端自检(17 项) + ├── selftest-talk-cut.mjs # 剪口播自检(11 项;没有草稿时落稿链显式 SKIP) + ├── talk-cut-plan.mjs # 口播保留区间规划器 + ├── talk-cut-apply.mjs # 口播落稿器(★ 支持 --sessionId 复用编排器的会话) + ├── add-subtitles.mjs # 字幕落稿器(SRT/plan → 带时码的文字段;★ 支持 --sessionId 复用会话) + └── selftest-subtitle.mjs # 字幕落稿自检 +``` + +## 要求 + +**平台**:Windows(10/11)。 + +**需要在本机存在的可执行文件**: + +- `ffmpeg` 6.0 —— 在 `PATH`,或用 `FFMPEG` 环境变量指定 +- Python 3.9+ —— 在 `PATH`,或用 `JYAI_PYTHON` 环境变量指定 +- `node` ≥ 22(运行 MCP server 与脚本) +- 剪映 11.6 —— **草稿相关动词需要它在运行**(走本机命名管道 `\\.\pipe\videoeditor-runtime-*`); + 调色与出片本身不依赖 GUI + +**其他**: + +- `JYAI_HOME` 指向 JYAI 工具链仓库(工具在 `JYAI_HOME/tools`) +- 剪映运行时缓存 `%LOCALAPPDATA%\JianyingPro\User Data\Cache`(读取官方 shader 参照源用) + +**账号 / 付费服务**:**不需要任何账号,不需要任何付费服务,不需要任何 API Key。** + +## 数据与网络 + +- **无网络访问。** 插件不发起任何 HTTP/HTTPS 请求,也不连任何远端服务。 +- **不需要凭据。** 不读不写任何 token / 密钥 / cookie。 +- **无遥测。** 不上报任何使用数据、错误或崩溃信息。 +- **无第三方服务。** 不依赖任何外部 API、SDK 或托管后端。 +- **本机 IPC**:草稿读写与官方渲染通过**本机命名管道**与正在运行的剪映通信(Windows 本地 IPC,非网络)。 +- **数据流向**:所有输入(视频、音频、草稿)只在本机被读取;所有产物(成片、帧、报告)写到本机输出目录。 +- **草稿只读**:插件**只读**用户草稿;写操作一律在副本上进行,落盘时逐文件 sha 断言真草稿未被改动。 +- 路径全部通过 `JYAI_HOME` / `HOME` / `LOCALAPPDATA` / `ProgramFiles` / `PATH` 解析,**不写死盘符**。 + +## 边界 + +- 改草稿的**结构化调色字段**(曲线/HSL/色轮):**路已经打开了**(`effect.add` / `adjustment.add` / `filter.add` / + `keyframe.*` / `video.setBasicColor` 实测全部放行,草稿里也有 `materials.hsl` / `hsl_curves` / `color_curves` / + `log_color_wheels` / `primary_color_wheels` 这些现成的 material 集合),但**参数契约还没探完** ⇒ 目前只能**本地出片**, + 不能写进剪映预览。 +- ★ **官方预览渲染器的能力边界**(实测,噪声底精确为 0 的同源对照):它**自带强度型调色** + (`materials.effects[]` 里的 `type:"brightness"` + 段引用)与几何/合成; + **不认**曲线 / HSL / 色轮 / effect 链 / `adjust` placeholder / forceRender 链。 + ⇒ 所以"把曲线写回剪映预览"这条路对**曲线类**是关的 —— 曲线类只能走我们自己的出片链(`make_film --grade`)。 +- 官方导出器不可达(见上)。 +- `clear`(清晰度,8 级金字塔)尚未接入链。 +- 口播剪辑**不做 ASR**:官方 ASR 在 agent runtime 里、拿不到;我们用**用户提供的 SRT**, + 只做"哪句是废话"的判定(清词/长停顿/重复),不做改写。**字幕落稿已能做**(用户给 SRT ⇒ `scripts/add-subtitles.mjs`)。 + +## 证据 + +调色链逐 feature 对齐、10bit 出片链路、官方参考帧获取、**官方行为操作全集(199 个)+ 字幕落稿**、判据纪律 —— +见 JYAI 仓库的 `docs/OFFICIAL-REALTIME-PATH.md`(§54–§100;其中 §100 是插件整体审计报告)与 `docs/最终架构与开发路线.md`。 diff --git a/plugins/antianqi/jianying/mcp.json b/plugins/antianqi/jianying/mcp.json new file mode 100644 index 00000000..bb491905 --- /dev/null +++ b/plugins/antianqi/jianying/mcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "jianying": { + "type": "stdio", + "command": "node", + "args": ["./mcp/server.mjs"], + "cwd": "./mcp" + } + } +} diff --git a/plugins/antianqi/jianying/mcp/server.mjs b/plugins/antianqi/jianying/mcp/server.mjs new file mode 100644 index 00000000..e12636d4 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/server.mjs @@ -0,0 +1,1660 @@ +#!/usr/bin/env node +/** + * jianying · MCP server(stdio,8 个动词) + * + * 设计纪律(见 docs/最终架构与开发路线.md): + * ① skill 不感知执行路径 —— 本 server 内部决定"离线写盘"还是"活体 edit 域",对 skill 透明 + * ② 机械验证与语义验证分离 —— 本 server 只做【机械验证】(护栏 + 探针 + 断言) + * ③ 判据优先 —— 入参用 schema 硬校验,非法参数直接拒绝,不让"静默失效"发生 + * + * 零 npm 依赖;路径全部走 JYAI_HOME / $HOME / %LOCALAPPDATA% 解析,绝不写死盘符。 + */ +import { createInterface } from 'node:readline'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = path.resolve(HERE, '..'); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +const TOOLS_DIR = path.join(HERE, 'tools'); +const SCRIPTS = path.join(PLUGIN_ROOT, 'scripts'); + +/* ---------------------------------------------------------------- 路径解析 */ +function jyaiTool(name) { + const p = path.join(JYAI, 'tools', name); + if (!fs.existsSync(p)) throw new Error(`JYAI 工具不存在: ${p}(请设 JYAI_HOME 指向 JYAI 仓库)`); + return p; +} +function jyaiData(...parts) { + return path.join(JYAI, ...parts); +} +function cacheDir() { + const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + return path.join(base, 'JianyingPro', 'User Data', 'Cache'); +} + +/* 可执行文件解析:env → PATH → 常见安装位置(绝不写死盘符) */ +function which(cmd, extra = []) { + if (process.env[cmd.toUpperCase().replace(/-/g, '_')]) return process.env[cmd.toUpperCase().replace(/-/g, '_')]; + const pathExt = (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';'); + for (const d of (process.env.PATH || '').split(path.delimiter)) { + if (!d) continue; + for (const ext of ['', ...pathExt]) { + const p = path.join(d, cmd + ext); + try { if (fs.existsSync(p) && fs.statSync(p).isFile()) return p; } catch { /* ignore */ } + } + } + for (const p of extra) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } } + return cmd; // 交给 spawn 报错 +} +// ★ 2026-10-22 审计:候选路径一律从 env 拼(ProgramFiles / LOCALAPPDATA),【不写盘符字面量】 +// (非 Windows 上这些 env 不存在 ⇒ 自然跳过,不产生"只能在 Windows 跑"的硬绑定) +const progFiles = () => (process.env.ProgramFiles || '').trim(); +const localApp = () => (process.env.LOCALAPPDATA || '').trim(); +const FFMPEG = () => which('ffmpeg', [ + ...(progFiles() ? [path.join(progFiles(), 'ffmpeg', 'ffmpeg.exe')] : []), +]); +// ★ 2026-10-22 审计:原来写死 Python314 ⇒ 装 3.11/3.12/3.13 的用户会找不到。改为按版本泛化 + 多个解释器名。 +const PY_VERS = ['Python314', 'Python313', 'Python312', 'Python311', 'Python310', 'Python39']; +const PYTHON = () => process.env.JYAI_PYTHON || which('python', [ + ...(progFiles() ? PY_VERS.map((v) => path.join(progFiles(), v, 'python.exe')) : []), + ...(localApp() ? PY_VERS.map((v) => path.join(localApp(), 'Programs', 'Python', v, 'python.exe')) : []), + ...(localApp() ? [path.join(localApp(), 'Programs', 'Python', 'Launcher', 'py.exe')] : []), +]) || which('python3', []) || 'python'; + +/* ------------------------------------------------- 轻量 JSON Schema 校验 */ +/* 只支持我们 schema 里用到的关键字:type/properties/required/additionalProperties/ */ + /* enum/minimum/maximum/items/patternProperties/default/oneOf-任意键 */ +const ERR = []; +function val(v, s, pathStr) { + if (!s || typeof s !== 'object') return; + if (s.type === 'object') { + if (v === null || typeof v !== 'object' || Array.isArray(v)) { ERR.push(`${pathStr}: 期望 object`); return; } + for (const r of s.required || []) if (!(r in v)) ERR.push(`${pathStr}: 缺少必填 "${r}"`); + const props = s.properties || {}; + for (const [k, vv] of Object.entries(v)) { + if (props[k]) { val(vv, props[k], `${pathStr}.${k}`); continue; } + let handled = false; + if (s.patternProperties) { + for (const [re, sub] of Object.entries(s.patternProperties)) { + if (new RegExp(re).test(k)) { val(vv, sub, `${pathStr}.${k}`); handled = true; break; } + } + } + if (handled) continue; + if (s.additionalProperties === false) ERR.push(`${pathStr}.${k}: 未知键(不合法)`); + else if (typeof s.additionalProperties === 'object') val(vv, s.additionalProperties, `${pathStr}.${k}`); + } + } else if (s.type === 'array') { + if (!Array.isArray(v)) { ERR.push(`${pathStr}: 期望 array`); return; } + if (s.items) v.forEach((it, i) => val(it, s.items, `${pathStr}[${i}]`)); + } else if (s.type === 'number' || s.type === 'integer') { + if (typeof v !== 'number' || Number.isNaN(v)) { ERR.push(`${pathStr}: 期望 number`); return; } + if (s.type === 'integer' && !Number.isInteger(v)) ERR.push(`${pathStr}: 期望 integer`); + if (s.minimum !== undefined && v < s.minimum) ERR.push(`${pathStr}: ${v} < 最小 ${s.minimum}`); + if (s.maximum !== undefined && v > s.maximum) ERR.push(`${pathStr}: ${v} > 最大 ${s.maximum}`); + } else if (s.type === 'string') { + if (typeof v !== 'string') { ERR.push(`${pathStr}: 期望 string`); return; } + if (s.enum && !s.enum.includes(v)) ERR.push(`${pathStr}: "${v}" 不在允许集合 [${s.enum.join(', ')}]`); + } else if (s.type === 'boolean') { + if (typeof v !== 'boolean') ERR.push(`${pathStr}: 期望 boolean`); + } +} +function validate(args, schema) { + ERR.length = 0; + val(args, schema, 'args'); + if (ERR.length) throw new Error(`入参不合法:\n - ${ERR.join('\n - ')}`); + // 填 default + const out = { ...args }; + const fill = (o, s) => { + if (!s || s.type !== 'object' || !o) return; + for (const [k, sub] of Object.entries(s.properties || {})) { + if (!(k in o) && sub && 'default' in sub) o[k] = sub.default; + } + }; + fill(out, schema); + return out; +} + +/* ------------------------------------------------------------ 跨动词参数复用校验 + ★ make_film 的 `grade` 与 grade_apply 的 `spec` 是【同一套链 API 语义】, + 但 make-film.schema.json 里 grade 的每个节点只写了 {type:object}(弱校验) + ⇒ 拼错的参数名会被【静默忽略】:烘出恒等 LUT(实测:cube 与空 spec 逐字节相同) + ⇒ 出片"成功"、验收"通过",但调色根本没发生。 + ⇒ 这里用 grade_apply 的 spec schema 再校验一次(单一真源,零重复声明), + 两个入口的校验强度从此天然一致。 */ +function validateCrossVerb(name, args) { + if (name !== 'make_film' || !args || !args.grade) return; + const t = TOOLLIST.find((x) => x.name === 'grade_apply'); + const specSchema = t?.inputSchema?.properties?.spec; + if (!specSchema) return; + ERR.length = 0; + val(args.grade, specSchema, 'args.grade'); + if (ERR.length) { + throw new Error(`入参不合法(grade 与 grade_apply 的 spec 是同一套契约):\n - ${ERR.join('\n - ')}`); + } +} + +/* ------------------------------------------------------------ 插件状态目录 */ +/* mcode 注入 ${PLUGIN_DATA}(每个实例独立);没有则用临时目录。 + ★ 动词之间靠它共享状态:draft_open 记 session → draft_patch 存改后草稿快照 → draft_commit 取快照落盘。 */ +function stateDir() { + return process.env.PLUGIN_DATA || path.join(os.tmpdir(), 'jianying-plugin-state'); +} + +/* ★★ 路径穿越防线(2026-10-23,PR review 抓到的合并阻塞项) + 原先 sessionId 被原样拼进文件名(`session-${sid}.json`),而 schema 只要求它是个字符串 + ⇒ `x/../../../../tmp/canary` 经 path.join 归一化后落到 PLUGIN_DATA 之外 + ⇒ draft_read 能读出任意本地 JSON,draft_patch 能往那里写快照。 + 现在三层挡住: + ① 形态:sessionId 必须是服务器签发的 `ve-session-<数字>`(不含分隔符、不含 `..`) + ② 规范包含:拼出来的路径必须严格位于状态目录之内(sibling-prefix 也要拒) + ③ 符号链接:状态目录解 realpath;已存在的符号链接文件一律拒绝(读与写都拒) */ +const SESSION_ID_RE = /^ve-session-\d+$/; +function assertSessionId(sid) { + if (sid === undefined || sid === null || sid === '') { + throw new Error('缺少 sessionId(先调 draft_open 拿)'); + } + if (typeof sid !== 'string' || !SESSION_ID_RE.test(sid)) { + throw new Error( + `sessionId 格式非法(只接受 draft_open 签发的 ve-session-<数字>):${String(sid).slice(0, 60)}`); + } + return sid; +} +/** p 必须严格位于 dir 之内:相等、sibling-prefix(dir2 与 dir 同前缀)都要拒 */ +function isInside(dir, p) { + const rel = path.relative(path.resolve(dir), path.resolve(p)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} +/** 状态目录:先建再解符号链接 ⇒ 后面拼的都是真实路径(review 要求覆盖 symlinked state dir) */ +function stateDirReal() { + const d = stateDir(); + try { fs.mkdirSync(d, { recursive: true }); } catch { /* 已存在或不可建 ⇒ 后面会报错 */ } + try { return fs.realpathSync(d); } catch { return path.resolve(d); } +} +/** 已存在的文件若是符号链接 ⇒ 拒绝(否则 realpath 会把读/写引到目录外) */ +function rejectSymlink(p) { + try { + if (fs.lstatSync(p).isSymbolicLink()) { + throw new Error('状态文件是符号链接,拒绝(防止指向状态目录之外)'); + } + } catch (e) { + if (e && e.message && e.message.includes('符号链接')) throw e; + /* 文件不存在 ⇒ 正常 */ + } +} +function statePathOf(sid, prefix) { + assertSessionId(sid); + const dir = stateDirReal(); + const p = path.join(dir, `${prefix}-${sid}.json`); + if (!isInside(dir, p)) throw new Error('状态文件路径逃出了状态目录'); + return { dir, p }; +} +function stateFile(sid) { + const { dir, p } = statePathOf(sid, 'session'); + if (fs.existsSync(p)) { + rejectSymlink(p); + const real = fs.realpathSync(p); + if (!isInside(dir, real)) throw new Error('状态文件解析后位于状态目录之外'); + } + return p; +} +function snapshotFile(sid) { + const { dir, p } = statePathOf(sid, 'snapshot'); + if (fs.existsSync(p)) { + rejectSymlink(p); + const real = fs.realpathSync(p); + if (!isInside(dir, real)) throw new Error('快照文件解析后位于状态目录之外'); + } + return p; +} +function writeState(sid, obj) { + const p = stateFile(sid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj), 'utf8'); +} +function readState(sid) { + const p = stateFile(sid); + if (!fs.existsSync(p)) return null; + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } +} +/* 素材路径重定位(照 jyai-live.mjs 的 relocateMaterialPaths):草稿搬过家后 path 失效 ⇒ 指回草稿目录内 */ +function relocateMaterialPaths(draft, draftDir) { + const fixes = []; + const materials = draft?.materials; + if (!materials || typeof materials !== 'object') return fixes; + for (const [kind, list] of Object.entries(materials)) { + if (!Array.isArray(list)) continue; + for (const m of list) { + if (!m || typeof m !== 'object') continue; + const mp = typeof m.media_path === 'string' ? m.media_path : null; + const p = typeof m.path === 'string' ? m.path : null; + if (!mp || !p) continue; + if (/^[A-Za-z]:[\\/]/.test(mp) || mp.startsWith('/')) continue; // 绝对路径 + if (fs.existsSync(p)) continue; // 已有效 + const candidate = path.join(draftDir, mp); + if (!fs.existsSync(candidate)) continue; + const sep = /\\/g.test(p) && !/\//.test(p) ? '\\' : '/'; + const newPath = candidate.replace(/[\\/]/g, sep); + fixes.push({ kind, from: p, to: newPath, media_path: mp }); + m.path = newPath; + } + } + return fixes; +} + +/* ------------------------------------------------------------ 管道直连(edit 域)*/ +/* 官方 behavior 操作是【带命名空间】的(segment.move / video.setVolume / track.add …), + 不是裸名。权威清单:docs/HEADLESS-VERIFY.md §4.8.2(27 个,实测 27/27)。 */ +const BS = String.fromCharCode(92); +const PIPE_PREFIX = `${BS}${BS}.${BS}pipe${BS}videoeditor-runtime-`; + +/* 每个 op 的【已知必填项】(从官方实测报错反推,如 video.add→video_path_required ⇒ params.path 必填) */ +const OP_REQUIRED = { + 'video.add': ['path'], + 'text.add': ['content'], + 'track.mute': ['trackId'], + 'track.delete': ['trackId'], + 'segment.move': ['segmentId'], + 'segment.setTimeRange': ['segmentId'], + // ★ 2026-10-21 实测确认(视频/音频 AI 效果类) + 'video.denoiseOn': ['segmentId'], + 'video.denoiseOff': ['segmentId'], + 'audio.setVocalBeautify': ['segmentId'], + 'audio.updateVocalBeautify': ['segmentId'], + 'audio.removeVocalBeautify': ['segmentId'], + 'audio.setVoiceEffect': ['segmentId'], + 'audio.setVoiceEffectParams': ['segmentId'], + 'audio.clearVoiceEffect': ['segmentId'], + 'audio.setVocalSeparation': [], + 'segment.setDescription': ['segmentId'], + 'segment.updateLayout': ['layout'], + 'segment.innerReplace': ['replacement'], +}; + +function vePipePid() { + try { + const out = execFileSync('powershell', ['-NoProfile', '-Command', + "Get-CimInstance Win32_Process -Filter \"Name='vebun.exe'\" | Select-Object -First 1 -ExpandProperty ParentProcessId"], + { encoding: 'utf8', timeout: 20000 }).trim(); + const n = Number(out); + if (Number.isInteger(n) && n > 0) return n; + } catch { /* ignore */ } + throw new Error('解析 vebun 父进程 pid 失败(剪映在跑吗?)'); +} +function pipeOnce(pid, domain, operation, params = {}, targetId, timeoutMs = 60000) { + return new Promise((resolve) => { + const env = { + protocolVersion: 1, + id: `jy-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`, + domain, operation, params, priority: 'interactive', + }; + if (targetId) env.targetId = targetId; + const s = net.connect(`${PIPE_PREFIX}${pid}`); + let buf = '', done = false; + const fin = (r) => { if (!done) { done = true; try { s.destroy(); } catch { /* ignore */ } resolve(r); } }; + const t = setTimeout(() => fin({ outcome: 'TIMEOUT', raw: buf }), timeoutMs); + s.on('connect', () => { try { s.write(JSON.stringify(env) + '\n'); } catch (e) { clearTimeout(t); fin({ outcome: 'WRITE_ERROR', error: String(e) }); } }); + s.on('data', (d) => { + buf += d.toString('utf8'); + const nl = buf.indexOf('\n'); + if (nl !== -1) { clearTimeout(t); fin({ outcome: 'RESPONSE', raw: buf.slice(0, nl) }); } + }); + s.on('error', (e) => { clearTimeout(t); fin({ outcome: 'CONNECT_ERROR', error: e.code || e.message }); }); + s.on('close', () => { clearTimeout(t); fin({ outcome: buf ? 'RESPONSE' : 'CLOSED_EMPTY', raw: buf }); }); + }); +} +async function editCall(op, params, sessionId) { + const pid = vePipePid(); + const r = await pipeOnce(pid, 'edit', op, params, sessionId, 30000); + if (r.outcome === 'CONNECT_ERROR') throw new Error(`管道连不上:${r.error}(剪映在跑吗?)`); + if (r.outcome === 'TIMEOUT') throw new Error(`edit/${op} 超时`); + const parsed = JSON.parse(r.raw); + if (parsed.ok === false) throw new Error(`edit/${op} 被拒:${JSON.stringify(parsed.error)}`); + const res = parsed.result || {}; + if (res.error) throw new Error(`edit/${op} 失败:${res.error}`); // 官方原样错误(如 segment_id_required) + return res; +} + +/* ------------------------------------------------------------- 工具执行 */ +function run(cmd, args, opts = {}) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { + cwd: opts.cwd || PLUGIN_ROOT, + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }, + windowsHide: true, + }); + const chunks = [], echunks = []; + p.stdout.on('data', (d) => chunks.push(d)); + p.stderr.on('data', (d) => echunks.push(d)); + p.on('error', (e) => resolve({ code: -1, stdout: opts.binary ? Buffer.alloc(0) : '', + stderr: String(e) })); + p.on('close', (code) => { + const so = Buffer.concat(chunks), se = Buffer.concat(echunks); + resolve({ code, + stdout: opts.binary ? so : so.toString('utf8'), + stderr: opts.binary ? se.toString('utf8') : se.toString('utf8') }); + }); + }); +} +const ok = (t) => ({ content: [{ type: 'text', text: typeof t === 'string' ? t : JSON.stringify(t, null, 2) }] }); +const fail = (t) => ({ content: [{ type: 'text', text: typeof t === 'string' ? t : JSON.stringify(t, null, 2) }], isError: true }); + +function shaTree(dir) { + const out = []; + const walk = (d) => { + let ents = []; + try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } + for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else { + const h = crypto.createHash('sha256'); + h.update(fs.readFileSync(p)); + out.push(`${path.relative(dir, p).replace(/\\/g, '/')}:${h.digest('hex').slice(0, 16)}`); + } + } + }; + walk(dir); + return out; +} + +/* ★★ 官方管道读草稿(能读【加密】草稿) + 实测(2026-10-24):官方 draft/readFile 的正确参数形态是 + { path: <草稿目录>/draft_content.json, rootPath: <草稿库根> } + ⇒ 返回 { accepted, cipherType, content, encrypted, exists },content 是【明文 JSON】。 + ⇒ 我们的 draft_read 原来只走 jyai-live 读磁盘 ⇒ 用户的加密草稿【根本读不出来】。 + ⇒ 下面这个函数负责兜底:磁盘读不出来(或内容是密文)时走官方管道。 */ +async function readDraftViaOfficialPipe(draftJsonPath, rootPath) { + const pid = vePipePid(); + const env = { + protocolVersion: 1, + id: `jy-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`, + domain: 'draft', operation: 'readFile', + params: { path: draftJsonPath, rootPath }, + priority: 'interactive', + }; + const r = await pipeOnce(pid, 'draft', 'readFile', env.params, undefined, 30000); + if (r.outcome !== 'RESPONSE') return null; + let parsed; + try { parsed = JSON.parse(r.raw); } catch { return null; } + if (parsed.ok === false) return null; + const res = parsed.result || {}; + if (res.error) return null; + if (!res.exists || !res.content) return null; + try { return { draft: JSON.parse(res.content), encrypted: !!res.encrypted, cipherType: res.cipherType }; } + catch { return null; } +} + +/* --------------------------------------------------------------- 草稿读与段级 diff + ★ 这两个函数被 verify_diff(timeline 维度)与 draft_patch(生效探针)共用: + - readDraftJson:草稿可能是官方加密封装 ⇒ 取第一个 '{' 之后解析 + - segmentDiffOf:段级字段差 —— "我到底改了什么",不依赖像素、不依赖官方自述 */ +/** 猜草稿库根:<库>/<草稿名>/draft_content.json ⇒ 库根是上上级(官方 readFile 的 rootPath 要的是它) */ +function guessDraftRoot(draftJsonPath) { + return path.dirname(path.dirname(path.resolve(draftJsonPath))); +} +function readDraftJson(p) { + let txt; + try { txt = fs.readFileSync(p, 'utf8'); } catch { return null; } + const i = txt.indexOf('{'); + if (i > 0) txt = txt.slice(i); + try { return JSON.parse(txt); } catch { return null; } +} +/* ★ 读一份草稿用于【前后对比】。 + 磁盘上的草稿是【加密封装】⇒ readDraftJson 解析不出来 ⇒ 直接返回 null + ⇒ 生效探针会以为"改前不存在",于是 changes=0 / effectVerified=false + ⇒ ★ 假阴性(实测:segment.split 明明把段切成了两半,探针却说"什么都没变")。 + 补救:读不出就走官方管道解密(它能读加密草稿)。 */ +async function readDraftForDiff(p, st) { + const local = readDraftJson(p); + if (local) return local; + try { + const via = await readDraftViaOfficialPipe(p, st?.draftDir || path.dirname(p)); + return via ? via.draft : null; + } catch { return null; } +} +function segmentDiffOf(before, after) { + const segMap = (d) => { + const m = new Map(); + for (const t of (d?.tracks || [])) for (const s of (t.segments || [])) m.set(s.id, s); + return m; + }; + const mb = segMap(before), ma = segMap(after); + const out = []; + for (const [id, s2] of ma) { + const s1 = mb.get(id); + if (!s1) { out.push({ segmentId: id, kind: 'added' }); continue; } + const fields = {}; + const morphOnly = []; + for (const k of new Set([...Object.keys(s1), ...Object.keys(s2)])) { + const v1 = JSON.stringify(s1[k]), v2 = JSON.stringify(s2[k]); + if (v1 === v2) continue; + // ★ 只在【两边都有】的字段上算"值变了";只在一边有的 ⇒ 是【形态差】(精简 vs 运行时展开), + // 不是这个 op 改的 ⇒ 单列 morphOnly,否则真实变化会被几十个形态字段淹没(实测踩过) + if (k in s1 && k in s2) fields[k] = { before: s1[k], after: s2[k] }; + else morphOnly.push(k); + } + // ★ fields 空但 morphOnly 有 ⇒ 也要保留(否则承载形态差的段被整段丢掉 ⇒ + // "这个字段只在展开形态里存在"这条信息就丢了 ⇒ expect 就分不清"没变"与"判不出来") + if (Object.keys(fields).length || morphOnly.length) { + out.push({ + segmentId: id, + kind: Object.keys(fields).length ? 'changed' : 'morph-only', + fields, morphOnly, + }); + } + } + for (const [id] of mb) if (!ma.has(id)) out.push({ segmentId: id, kind: 'removed' }); + return out; +} +/** 段数 / 时长摘要(timeline 维度也用) */ +function draftShape(d) { + const tracks = d?.tracks || []; + let segs = 0, dur = 0; + for (const t of tracks) for (const s of (t.segments || [])) { + segs++; + const tr = s.target_timerange || {}; + dur = Math.max(dur, (tr.start || 0) + (tr.duration || 0)); + } + return { tracks: tracks.length, segments: segs, durationUs: dur, draftDurationUs: d?.duration }; +} +/* ★ 某字段在【改前/改后】是不是【可比】。 + 段级 diff 的 fields 只装【变了】的字段 ⇒ "没变"与"根本没法比"在 diff 里长得一样 + ⇒ "segmentFieldUnchanged" 这个断言会被静默满足(假绿)。 + ⇒ 这里单独数一遍:两边都有的段数(可比)/ 只有一边有的段数(形态差)。 */ +function fieldComparability(db, da, field) { + const map = (d) => { const m = new Map(); for (const t of (d?.tracks || [])) for (const s of (t.segments || [])) m.set(s.id, s); return m; }; + const mb = map(db), ma = map(da); + let both = 0, oneSide = 0; + for (const [id, s2] of ma) { + const s1 = mb.get(id); + if (!s1) continue; + const in1 = field in s1, in2 = field in s2; + if (in1 && in2) both++; + else if (in1 || in2) oneSide++; + } + return { comparable: both, oneSided: oneSide }; +} + +/* --------------------------------------------------------------- 编排器辅助 */ +/** 把步骤参数里的 $变量 换成实际值(对象/数组递归) */ +function substVars(obj, vars) { + if (typeof obj === 'string') { + // ★ 整串就是某个变量 ⇒ 【返回那个变量本身】���保留类型) + // (否则 $grade 会被 JSON 字符串化 ⇒ make_film 收到字符串而不是对象 ⇒ 烘 LUT 时炸) + const whole = /^\$[A-Za-z_][A-Za-z0-9_]*$/.exec(obj); + if (whole) { + const v = vars[whole[0]]; + return v === undefined ? obj : v; + } + return obj.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, (m) => { + const v = vars[m]; + if (v === undefined || v === null) return m; // 没给的变量保持原样 ⇒ 后面会报"变量缺失" + return typeof v === 'object' ? JSON.stringify(v) : String(v); + }); + } + if (Array.isArray(obj)) return obj.map((x) => substVars(x, vars)); + if (obj && typeof obj === 'object') { + const out = {}; + for (const [k, v] of Object.entries(obj)) out[k] = substVars(v, vars); + return out; + } + return obj; +} +/** 解析 ok()/fail() 的 content 文本:能当 JSON 就当,否则原样留 */ +function parseOkPayload(r) { + const t = (r?.content || []).map((c) => c.text || '').join('\n'); + try { return JSON.parse(t); } catch { return { raw: t }; } +} +/** verify_diff 的紧凑摘要:保留每个维度的【关键数字】,丢掉逐帧明细(frameDetail 会把报告撑爆) */ +function summarizeVerify(obj) { + const c = obj?.checks || {}; + const out = { verdict: obj?.verdict, problems: obj?.problems || [] }; + // ★ "未判定的维度"必须进报告 ⇒ 否则 PASS 会被读成"全查过了" + if (obj?.undetermined) out.undetermined = obj.undetermined; + if (obj?.verdictNote) out.verdictNote = obj.verdictNote; + const ck = {}; + if (c.visibility) { + const v = c.visibility; + ck.visibility = v.skipped + ? { skipped: v.skipped, noiseMad: v.noiseMad, noiseChangedFrames: v.noiseChangedFrames, totalFrames: v.totalFrames, note: v.note } + : { verdict: v.verdict, maxMad: v.maxMad, visibleMaxMad: v.visibleMaxMad, visibleFrameCount: v.visibleFrameCount, + noiseMad: v.noiseMad, calibrated: v.calibrated, madOverNoise: v.madOverNoise, totalFrames: v.totalFrames }; + } + if (c.picture) { + const p = c.picture; + ck.picture = { afterMean: p.afterMean, clipRatio: p.clipRatio, overRatio: p.overRatio, underRatio: p.underRatio, + clipDelta: p.clipDelta, underDelta: p.underDelta, pixels: p.pixels, verdict: p.verdict, note: p.note }; + } + if (c.audio) { + const a = c.audio; + ck.audio = a.skipped ? { skipped: a.skipped } + : { before: a.before, after: a.after, note: a.note, undeterminedReason: a.undeterminedReason }; + } + if (c.timeline) { + const t = c.timeline; + ck.timeline = t.skipped ? { skipped: t.skipped } + : { before: t.before, after: t.after, segmentsDelta: t.segmentsDelta, durationDeltaUs: t.durationDeltaUs, + segmentDiffCount: (t.segmentDiffs || []).length, single: t.single, note: t.note }; + } + if (c.draft_integrity) ck.draft_integrity = c.draft_integrity; + out.checks = ck; + return out; +} + +/** 汇报里只留关键字段(不然 make_film 的 stdout 会把报告撑爆) */ +function summarizeStep(obj) { + if (!obj || typeof obj !== 'object') return obj; + const keep = ['sessionId', 'dir', 'committed', 'encrypted', 'realDraftUnchanged', + 'verdict', 'failureKind', 'problems', 'effectVerified', 'changesCount', 'baselineKind']; + const out = {}; + for (const k of keep) if (k in obj) out[k] = obj[k]; + // ★ draft 有两种含义:draft_open 返回的是【草稿名】(字符串);draft_read 返回的是【整份草稿】(对象) + // ⇒ 字符串才留名字;对象换成形状摘要(不然一份草稿几万字,报告会被撑爆) + if (typeof obj.draft === 'string') out.draft = obj.draft; + else if (obj.draft && typeof obj.draft === 'object') out.draftShape = draftShape(obj.draft); + if (obj.raw) out.rawTail = String(obj.raw).replace(/\s+/g, ' ').slice(-300); + return Object.keys(out).length ? out : { rawTail: String(obj.raw || '').slice(-200) }; +} + +/* ------------------------------------------ 编排器:工作目录 / 脚本步骤 / 确认点 */ +/* ★ 两段式(人工确认点)必须把进度【持久化】:MCP 调用一断,关卡后面那几步就接不上了。 + 目录放在状态目录下(跟着 PLUGIN_DATA 走 ⇒ 同机子进程也看得见同一份状态)。 */ +const PIPE_TOKEN_RE = /^[0-9a-f]{16}$/; +function pipelineWorkDir(token) { + if (!PIPE_TOKEN_RE.test(String(token || ''))) { + throw new Error('流水线 token 非法(只接受 16 位小写十六进制)'); + } + const base = path.join(stateDirReal(), 'pipelines'); + fs.mkdirSync(base, { recursive: true }); + const d = path.join(base, token); + if (!isInside(base, d)) throw new Error('流水线目录逃出了状态目录'); + fs.mkdirSync(d, { recursive: true }); // ★ 产物(plan.json / before-draft.json)直接写在这里 + return d; +} +function pipeStatePath(workDir) { + const p = path.join(workDir, 'state.json'); + if (!isInside(workDir, p)) throw new Error('流水线状态文件逃出了工作目录'); + return p; +} +function savePipeState(workDir, obj) { + fs.mkdirSync(workDir, { recursive: true }); + fs.writeFileSync(pipeStatePath(workDir), JSON.stringify(obj, null, 2), 'utf8'); +} +function loadPipeState(workDir) { + const p = pipeStatePath(workDir); + if (!fs.existsSync(p)) return null; + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } +} +function shaFile(p) { + try { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } + catch { return null; } +} + +/* ★ 脚本步骤:跑插件 scripts/ 下的脚本。 + args 的【键】就是命令行 flag("--srt": "$srt")⇒ 值可以是变量引用; + 值为 true ⇒ 裸 flag(--apply);false/null/undefined ⇒ 不传。 */ +async function runScriptStep(st, vars) { + const scriptsDir = path.join(PLUGIN_ROOT, 'scripts'); + if (!fs.existsSync(scriptsDir)) throw new Error('插件里没有 scripts/ 目录'); + const sp = path.join(scriptsDir, st.script); + if (!fs.existsSync(sp)) throw new Error(`脚本不存在:scripts/${st.script}`); + const realDir = fs.realpathSync(scriptsDir); + const realSp = fs.realpathSync(sp); + if (!isInside(realDir, realSp)) throw new Error(`脚本路径逃出了 scripts/:${st.script}`); + const argv = []; + for (const [k, v0] of Object.entries(st.args || {})) { + if (!k.startsWith('--')) throw new Error(`脚本步骤的键必须是命令行 flag(--xxx),收到 "${k}"`); + const v = substVars(v0, vars); + if (v === undefined || v === null || v === false) continue; // false/null ⇒ 不传 + if (v === true) { argv.push(k); continue; } // true ⇒ 裸 flag + argv.push(k, String(v)); + } + const r = await run(process.execPath, [realSp, ...argv]); + if (r.code !== 0) { + throw new Error(`脚本 ${st.script} 失败(退出码 ${r.code}):\n${String(r.stderr || r.stdout).slice(0, 600)}`); + } + return r; +} + +/* ★ 确认点:跑完这一步就停下来等人确认。 + 产物必须由路线显式声明("artifact": "$planPath")⇒ 编排器不猜、不自己找。 */ +function buildGate(st, vars, prof, stepNo, scriptOut) { + const av = st.artifact || null; + let artifact = null, artifactSha = null, preview = null; + if (av) { + artifact = vars[av] || null; + if (!artifact) throw new Error(`确认点声明的产物变量 ${av} 没有值`); + if (!fs.existsSync(artifact)) throw new Error(`确认点的产物文件不存在:${artifact}`); + artifactSha = shaFile(artifact); + preview = { sizeBytes: fs.statSync(artifact).size }; + try { + const j = JSON.parse(fs.readFileSync(artifact, 'utf8')); + preview.keys = Object.keys(j); + if (j.stats) preview.stats = j.stats; + if (Array.isArray(j.dropRanges)) preview.dropRanges = j.dropRanges.length; + } catch { preview.keys = ['<不是 JSON>']; } + } + return { + step: stepNo, + script: st.script || null, + reason: st.confirmNote || '这一步之后需要人工确认', + artifact, artifactSha, artifactPreview: preview, + stdoutTail: String(scriptOut?.stdout || '').replace(/\s+/g, ' ').slice(-400), + pendingSteps: prof.steps.length - stepNo, + }; +} + +/* ★ 跑一串步骤(第一段与续跑共用这一份逻辑 ⇒ 两边不会走偏)。 + 遇到确认点就停(返回 gate);跑完返回 done;出错返回 failed + 停在第几步。 */ +async function execSteps(prof, vars, workDir, startIdx, stepsIn) { + const steps = stepsIn.slice(); + for (let i = startIdx; i < prof.steps.length; i++) { + const st = prof.steps[i]; + const n = i + 1; + try { + if (st.script) { + const r = await runScriptStep(st, vars); + steps.push({ + n, script: st.script, ok: true, + result: { stdoutTail: String(r.stdout).replace(/\s+/g, ' ').slice(-300) }, + }); + if (st.confirm) return { gate: buildGate(st, vars, prof, n, r), steps }; + continue; + } + const impl = IMPL[st.verb]; + if (!impl) throw new Error(`路线 "${prof.id}" 第 ${n} 步用了不存在的动词 ${st.verb}`); + // ★ 这一步【产出】的文件:声明式("produces": {"$film": "film.mp4"})⇒ 相对 $outDir 解析 + // ★ 【先分配再执行】⇒ 这一步自己的参数也能引用它(例:render_frames 的 outDir 就是产目录) + for (const [vn, fname] of Object.entries(st.produces || {})) { + if (!vars.$outDir) { + throw new Error(`路线 "${prof.id}" 第 ${n} 步声明了产物 ${vn},但没有 $outDir(产物路径相对输出目录解析)`); + } + vars[vn] = path.join(vars.$outDir, String(fname)); + } + // ★ 参数里还留着 $xxx 的 ⇒ 说明那个变量没人提供 ⇒ 明确报错(不猜) + const args = substVars(st.args || {}, vars); + const leftover = JSON.stringify(args).match(/\$[A-Za-z_][A-Za-z0-9_]*/g) || []; + if (leftover.length) { + throw new Error(`路线 "${prof.id}" 第 ${n} 步缺少变量:${[...new Set(leftover)].join(', ')}`); + } + const r = await impl(args); + const obj = parseOkPayload(r); + // 收集这一步的产出,供后面步骤用 + if (st.verb === 'draft_open') { + if (obj.sessionId) vars.$sessionId = obj.sessionId; + if (obj.dir) vars.$dir = obj.dir; + } + if (st.verb === 'draft_read') { + const seg = ((obj.draft?.tracks) || []).flatMap((t) => t.segments || [])[0]; + if (seg?.id) vars.$segmentId = seg.id; + // ★ 文件名带步骤号 ⇒ 一份路线里读多份草稿时不会互相覆盖 + const f = path.join(workDir, `draft-${n}.json`); + fs.writeFileSync(f, JSON.stringify(obj.draft ?? obj, null, 2), 'utf8'); + vars.$beforeDraft = f; + // ★ 中性别名:同一个文件也叫 $draftJson ⇒ "这一版的草稿 JSON"(审片路线用它, + // 不必知道编排器内部把它叫 before-draft.json) + vars.$draftJson = f; + } + if (st.verb === 'draft_commit') { + const after = await IMPL.draft_read({ sessionId: vars.$sessionId }); + const ao = parseOkPayload(after); + const f = path.join(workDir, 'after-draft.json'); + fs.writeFileSync(f, JSON.stringify(ao.draft ?? ao, null, 2), 'utf8'); + vars.$afterDraft = f; + } + steps.push({ + n, verb: st.verb, ok: true, + // ★ 结论类动词走【专用摘要】⇒ 否则"跑成了却没产出它该产出的东西" + // (verify_diff 的结论在 checks 里、grade_apply 的探针在 probe 里,都不在白名单上) + result: st.verb === 'verify_diff' ? summarizeVerify(obj) + : st.verb === 'grade_apply' + ? { out: obj.out, outLut: obj.outLut, gradeChangeMad: obj.gradeChangeMad, probe: obj.probe } + : summarizeStep(obj), + }); + // ★ capture:从【这一步的响应】里取一个值当变量 + // (例:grade_apply 的 out = 10bit 成片的确切路径,只有响应知道) + // ★ 响应里没有这个字段 ⇒ 明确报错(不静默塞 undefined) + for (const [vn, field] of Object.entries(st.capture || {})) { + const v = obj?.[field]; + if (v === undefined || v === null) { + throw new Error(`路线 "${prof.id}" 第 ${n} 步要把响应里的 "${field}" 取作 ${vn},但响应里没有这个字段`); + } + vars[vn] = v; + } + // ★ setVars:给变量起别名 / 派生新变量(值可以含 $变量 ⇒ 走模板替换) + // ★ 时机在【这一步之后】⇒ 它抓的就是这一步的产出(例:draft_open 的 $sessionId / $dir) + // 为什么需要:一份路线里开两次草稿 ⇒ $dir / $sessionId 会被第二次覆盖 + // ⇒ 必须先把第一个的产出"存"到别的名字下(例:$dirBefore / $sidBefore) + for (const [vn, tmpl] of Object.entries(st.setVars || {})) { + const v = substVars(String(tmpl), vars); + const leftover = String(v).match(/\$[A-Za-z_][A-Za-z0-9_]*/g) || []; + if (leftover.length) { + throw new Error(`路线 "${prof.id}" 第 ${n} 步的 setVars ${vn} 缺少变量:${[...new Set(leftover)].join(', ')}`); + } + vars[vn] = v; + } + } catch (e) { + const msg = String(e?.message || e); + steps.push({ n, verb: st.verb || st.script || '?', ok: false, error: msg.slice(0, 400) }); + return { failed: true, error: msg, failedStep: n, steps }; + } + } + return { done: true, steps }; +} + +/** 路线还缺哪些【必需输入】(依据 profile.inputs 的声明) + * ★ 一次性点名,而不是"跑到第 3 步才说缺 $srt"; + * ★ 并且标出哪些【必须由用户提供】(官方 ASR 拿不到 / 曲线形状猜不出来这类) */ +function missingInputsOf(prof, vars) { + const out = []; + for (const inp of prof.inputs || []) { + if (inp.required === false) continue; + const v = vars[inp.name]; + if (v === undefined || v === null || v === '') out.push(inp); + } + return out; +} + +/* --------------------------------------------------------------- 8 个动词 */ +const IMPL = { + async draft_open(a) { + if (a.allowReal && a.copy !== false) { + throw new Error('allowReal=true 时必须显式 copy=false(开真草稿)'); + } + const copy = a.copy !== false; + const args = ['open', a.draft]; + if (copy) args.push('--copy'); + const r = await run(process.execPath, [jyaiTool('jyai-live.mjs'), ...args]); + if (r.code !== 0) throw new Error(`打开草稿失败:\n${r.stderr || r.stdout}`); + const txt = r.stdout; + // 解析出 sessionId / draft / dir(draft_patch 需要 sessionId) + const sid = (txt.match(/ve-session-\d+/) || [])[0] || null; + const dir = (txt.match(/dir\s+(\S+)/) || [])[1] || null; + const dname = (txt.match(/draft\s+(\S+)/) || [])[1] || null; + if (!sid) throw new Error(`打开草稿的输出里找不到 sessionId:\n${txt}`); + // 记状态(供 draft_patch / draft_commit 共享) + const draftDir = dir || null; + const st = { sessionId: sid, draft: dname, draftDir, copied: copy, + draftPath: draftDir ? path.join(draftDir, 'draft_content.json') : null, + // ★ 会话开始时【源草稿】的 sha ⇒ draft_commit 在落盘【前】比对: + // 防止"会话期间真草稿被别人改了"被我们用旧版本【覆盖】掉。 + // (原有的落盘前后比对只能抓"我们自己误写了真草稿",抓不到这一种) + realSha: a.draft ? shaTree(a.draft) : null, + openedAt: new Date().toISOString() }; + writeState(sid, st); + // ★★ 剪映的 session id 会【回收复用】(剪映进程重启后重新计数) + // ⇒ 新会话可能拿到一个【以前用过的 id】⇒ 状态目录里还留着"上一次那个会话"的 snapshot + // ⇒ draft_read 会优先读到那份旧快照 ⇒ 拿到【完全无关的另一份草稿】(实测踩过: + // session 155 读出来的是 10-22 那天的草稿,段数/形态/内容全错) + // ⇒ 开新会话时,先把这个 id 的旧快照清掉。 + try { fs.rmSync(snapshotFile(sid), { force: true }); } catch { /* 没有就算了 */ } + return ok({ copy, sessionId: sid, draft: dname, dir: draftDir, raw: txt }); + }, + + async draft_read(a) { + // ★ 优先读【会话内存快照】(draft_patch 存的那份);没有才读磁盘。 + // 否则会出现:patch 改了会话内存,read 却读到没落盘的磁盘 ⇒ 回读拿到旧草稿 ⇒ 编排全错。 + let parsed = null, source = 'disk'; + if (a.sessionId) { + // ★ 给了 sessionId 就必须是服务器签发的形态(路径穿越防线,见 assertSessionId) + assertSessionId(a.sessionId); + const snapP = snapshotFile(a.sessionId); + if (fs.existsSync(snapP)) { + try { parsed = JSON.parse(fs.readFileSync(snapP, 'utf8')); source = 'session'; } catch { /* ignore */ } + } + } + if (!parsed) { + // ★ 没有可用的会话快照 ⇒ 回落到【会话里记着的那个草稿】(draft_open 存进 state 的路径) + // ★★ 原实现是回落去 `jyai-live read`(不带 --path)⇒ 读到【某个默认草稿】: + // 一个无效的 sessionId 也能换回一份草稿内容 —— 既是不必要的暴露,也是假的"成功"。 + // 现在回落只用"调用者给的 path"或"这个会话自己记着的草稿",两者都没有就报错。 + const st = a.sessionId ? readState(a.sessionId) : null; + const fallbackPath = a.path || st?.draftPath || null; + if (!fallbackPath) { + throw new Error(a.sessionId + ? `找不到 session ${a.sessionId} 对应的草稿(先调 draft_open,或直接给 path)` + : '缺少可读目标:给 sessionId(draft_open 签发的 ve-session-<数字>)或 path(草稿 json 的路径)'); + } + const args = ['read', '--json', '--path', fallbackPath]; + // ★ 必须带 --root:不带的话官方 readFile 解析不出 workspace ⇒ 静默返回空 + // (2026-10-21 实测:exists=undefined chars=0)⇒ 根目录默认取 path 的上级目录 + const root = a.root || st?.draftDir || path.dirname(fallbackPath); + if (root) args.push('--root', root); + const r = await run(process.execPath, [jyaiTool('jyai-live.mjs'), ...args]); + if (r.code !== 0) throw new Error(`读取草稿失败:\n${r.stderr || r.stdout}`); + // jyai-live read 会在 JSON 之前打一行状态(exists=... encrypted=... cipherType=...)⇒ 剥掉 + const i = r.stdout.indexOf('{'); + const body = i >= 0 ? r.stdout.slice(i) : r.stdout; + try { parsed = JSON.parse(body); source = 'disk'; } + catch { + // ★ 磁盘上的草稿是【加密封装】⇒ jyai-live 读不出来 ⇒ 走官方管道(它能解密) + const viaPipe = await readDraftViaOfficialPipe(fallbackPath, a.root || guessDraftRoot(fallbackPath)); + if (viaPipe) { parsed = viaPipe.draft; source = 'official-pipe'; } + else return ok(r.stdout); + } + } + if (parsed) { + // ★ 会话状态里记下草稿 id ⇒ 读到的草稿 id 对不上就【点名】(不给一份"别的草稿"而不说) + // 实测踩过:剪映回收 session id ⇒ 读到了上一次那个会话的旧快照(另一份草稿) + const sid = a.sessionId; + const st = sid ? readState(sid) : null; + let draftIdMismatch = false; + if (st && parsed.id) { + if (st.draftId && st.draftId !== parsed.id) draftIdMismatch = true; + else if (!st.draftId) { st.draftId = parsed.id; writeState(sid, st); } + } + // 附一份"好用的摘要":track/segment 的 id 与时间范围(供 skill 直接用) + const summary = { source, draftId: parsed.id || null, draftIdMismatch, tracks: (parsed.tracks || []).map((t) => ({ + type: t.type, id: t.id, segments: (t.segments || []).map((s) => ({ + id: s.id, target_timerange: s.target_timerange, material_id: s.material_id, + })), + })) }; + return ok({ draft: parsed, summary }); + } + return ok('(空)'); + }, + + async draft_patch(a) { + // 护栏:已知必填项本地先拦(不白跑一次官方调用) + const need = OP_REQUIRED[a.op]; + if (need) { + const p = a.params || {}; + const miss = need.filter((k) => p[k] === undefined || p[k] === null || p[k] === ''); + if (miss.length) throw new Error(`op "${a.op}" 缺少必填参数:${miss.join(', ')}(放在 params 里)`); + } + // ★ 入口就校验 sessionId 形态(路径穿越防线):缺失与格式非法分开说 + assertSessionId(a.sessionId); + const st = readState(a.sessionId); // ★ 生效探针要用会话里记着的"改前草稿路径" + const res = await editCall(a.op, a.params || {}, a.sessionId); + // ★ 官方 edit 响应里的 diff.operations[0].value = 【改后的完整草稿】 + // ⇒ 存成快照,供 draft_commit 落盘(动词之间靠插件状态目录共享状态) + const ops = res.diff?.operations || []; + const full = ops.find((o) => o.entity === 'draft' && o.path === '/')?.value + || ops.find((o) => o.value && typeof o.value === 'object' && o.value.tracks)?.value; + let snapshot = null; + if (full) { + const snapP = snapshotFile(a.sessionId); // ★ 内含包含性 + 符号链接检查 + fs.mkdirSync(path.dirname(snapP), { recursive: true }); + fs.writeFileSync(snapP, JSON.stringify(full), 'utf8'); + snapshot = snapP; + } + // ★★ 生效探针:官方说"改了"不算数 —— 我们自己算"改前 vs 改后"的段级 diff + // 起因(实测):官方 audio.add 返回 accepted:true / changedPaths:["/"], + // 但草稿里 materials.audios 仍是 0 条 ⇒ 官方自述是个【空承诺】。 + let changes = null, effectVerified = null, effectNote = null, baselineFull = null; + let baselineSegKeys = null, afterSegKeys = null; + let before = null; // ★ 改前的草稿(供 expect 断言复用) + // ★★ 基线必须是【上一次 op 之后的状态】(会话快照),不是磁盘上的原草稿 + // 否则连续多个 op 时,会把【上一次】的改动算成【这一次】的 ⇒ 探针会说谎 + // (实测踩过:audio.add 什么都没改,但因为前面 setVolume 改过 ⇒ 误报"生效") + const snapBase = snapshotFile(a.sessionId); + const baseP = (fs.existsSync(snapBase) && fs.existsSync(snapBase)) + ? snapBase + : (st && st.draftPath && fs.existsSync(st.draftPath) ? st.draftPath : null); + if (full && baseP) { + before = await readDraftForDiff(baseP, st); + if (before) { + changes = segmentDiffOf(before, full); + // ★ 基线形态:磁盘草稿是剪映自己存的【精简形态】,官方返回是【运行时展开形态】 + // ⇒ 基线不完整时,diff 只覆盖交集字段;只在完整形态里才有的字段(如 volume)判不出来 + const bSeg = (before.tracks || []).flatMap((x) => x.segments || [])[0] || {}; + const aSeg = (full.tracks || []).flatMap((x) => x.segments || [])[0] || {}; + baselineSegKeys = Object.keys(bSeg).length; + afterSegKeys = Object.keys(aSeg).length; + baselineFull = baselineSegKeys > 20; + effectVerified = changes.length > 0; + if (!effectVerified) { + effectNote = '官方返回 accepted,但【草稿里什么都没变】(段数/段字段都没动)' + + '⇒ 这个 op 可能没真正生效(实测:官方 audio.add 就是这样)。' + + '要确认就用 verify_diff 的 timeline 维度(语义判据),别只信 accepted。'; + } + } + } + // ★ 给了 expect ⇒ 按 expect 断言(和 verify_diff 同一套语义):没变就【判失败】 + if (a.expect && changes) { + const exp = a.expect; + if (exp.segmentsDelta !== undefined) { + const baseShape = before ? draftShape(before) : null; + if (!baseShape) { + throw new Error(`op "${a.op}" 的 expect.segmentsDelta 无法判定:读不出改前的草稿(${baseP})`); + } + const now = draftShape(full).segments - baseShape.segments; + if (now !== exp.segmentsDelta) { + throw new Error(`op "${a.op}" 的 expect 不成立:段数变化 ${now} ≠ 期望 ${exp.segmentsDelta}(改动可能没落盘)`); + } + } + if (exp.segmentFieldChanged) { + const hit = changes.some((d) => d.fields && d.fields[exp.segmentFieldChanged]); + const morphHit = changes.some((d) => (d.morphOnly || []).includes(exp.segmentFieldChanged)); + if (!hit) { + if (morphHit) { + throw new Error(`op "${a.op}" 的 expect【无法判定】:"${exp.segmentFieldChanged}" 只在【运行时展开形态】里存在,` + + '而基线是【精简形态】(剪映自己存的那种)⇒ 这个字段本来就比不出来。' + + '要判定它,请在同一 session 里先做一次别的 patch(让基线变成展开形态),再判。'); + } + throw new Error(`op "${a.op}" 的 expect 不成立:没有任何 segment 的 "${exp.segmentFieldChanged}" 字段发生变化` + + '(改动可能没落盘 —— 官方 accepted 不代表真生效)'); + } + } + if (exp.segmentFieldUnchanged) { + const hit = changes.some((d) => d.fields && d.fields[exp.segmentFieldUnchanged]); + if (hit) { + throw new Error(`op "${a.op}" 的 expect 不成立:不该变的 "${exp.segmentFieldUnchanged}" 字段却变了`); + } + // ★ "没变"必须区分"【无法判定】"(字段不在交集里 ⇒ 视野不够) + const cmp = fieldComparability(before, full, exp.segmentFieldUnchanged); + if (cmp.comparable === 0) { + throw new Error(cmp.oneSided > 0 + ? `op "${a.op}" 的 expect【无法判定】:"${exp.segmentFieldUnchanged}" 只在【一边】的形态里存在` + + '(改前/改后形态不同)⇒ 不能断言它"没变"。请在同一 session 里先做一次别的 patch(让基线变成同一形态),再判。' + : `op "${a.op}" 的 expect【无法判定】:"${exp.segmentFieldUnchanged}" 在改前/改后都不存在`); + } + } + effectVerified = true; + } + return ok({ + op: a.op, + accepted: res.accepted !== false, + actionKey: res.actionKey, + baseRevision: res.diff?.baseRevision, + changedPaths: ops.map((o) => o.path), + snapshot, snapshotSaved: !!snapshot, + // ★ 生效证据(与 grade_apply 的逐节点探针同一族) + effectVerified, effectNote, + // ★ baselineKind:'full' = 基线是运行时展开形态(判定可信) + // 'morph' = 基线是精简形态(只有交集字段可判) + baselineKind: changes ? (baselineFull ? 'full' : 'morph') : null, + // ★ 诊断/可信度:基线与改后各自的段键数(11 ⇒ 精简形态;50+ ⇒ 运行时展开形态) + baselineSegKeys: baselineSegKeys ?? null, + afterSegKeys: afterSegKeys ?? null, + changes: changes ? changes.slice(0, 20) : null, + changesCount: changes ? changes.length : null, + }); + }, + + async draft_commit(a) { + const sid = a.sessionId; + // ★ 入口就校验形态(路径穿越防线):缺失与格式非法要分开说 + assertSessionId(sid); + const st = readState(sid); + if (!st) throw new Error(`找不到 session ${sid} 的状态(先调 draft_open)`); + const snapP = snapshotFile(sid); + if (!fs.existsSync(snapP)) { + throw new Error('没有改后草稿快照(先用 draft_patch 改一处再落盘)'); + } + const draft = JSON.parse(fs.readFileSync(snapP, 'utf8')); + + // 护栏 1:真草稿 sha 前后比对 + let before = null; + if (a.realDraft) before = shaTree(a.realDraft); + + // 护栏 1b:真草稿在【会话期间】被改过 ⇒ 拒绝落盘 + // ★ 护栏 1 是"落盘前 vs 落盘后",只能抓"我们自己误写了真草稿"; + // 这里比的是"会话开始时 vs 现在" ⇒ 抓"别人在会话期间改了真草稿", + // 否则我们会把用户的修改【覆盖】掉(静默丢数据)。 + if (a.realDraft && st.realSha) { + const nowSha = shaTree(a.realDraft); + const same = nowSha.length === st.realSha.length + && nowSha.every((x, i) => x === st.realSha[i]); + if (!same) { + throw new Error('★ 真草稿在本次会话期间被改动过(会话开始时的 sha 与现在不一致)' + + '——拒绝落盘,避免覆盖你的修改。重新 draft_open 再改。'); + } + } + + // 护栏 2:素材路径重定位(草稿搬过家后 path 失效 ⇒ 指回草稿目录内) + const draftDir = st.draftDir || ''; // ★ 入参里的 draftDir 是死代码:schema 拒未知键 ⇒ 永远取不到(审计发现) + const fixes = relocateMaterialPaths(draft, draftDir); + const target = st.draftPath || path.join(draftDir, 'draft_content.json'); + const rootPath = draftDir || path.dirname(target); + + // 落盘:draft/writeFile 传明文 JSON,服务端写时自动加密(sealed on write) + const r = await pipeOnce(vePipePid(), 'draft', 'writeFile', + { path: target, rootPath, content: JSON.stringify(draft) }, undefined, 60000); + if (r.outcome !== 'RESPONSE') throw new Error(`writeFile 失败:${r.outcome}`); + const parsed = JSON.parse(r.raw); + if (parsed.ok === false) throw new Error(`writeFile 被拒:${JSON.stringify(parsed.error)}`); + const wr = parsed.result || {}; + + // 护栏 3:真草稿必须零改动 + let realOk = null; + if (a.realDraft) { + const after = shaTree(a.realDraft); + realOk = before.length === after.length && before.every((x, i) => x === after[i]); + if (!realOk) throw new Error('★ 真草稿被改动了(sha 不一致)——已判定失败'); + } + return ok({ + committed: true, encrypted: wr.encrypted, cipherType: wr.cipherType, + relocated: fixes.length, target, realDraftUnchanged: realOk, + }); + }, + + async render_frames(a) { + const args = [jyaiTool('jyai-b-render.mjs'), '--draft', a.draft, '--out', a.outDir]; + if (a.fps) args.push('--fps', String(a.fps)); + const r = await run(process.execPath, args); + if (r.code !== 0) throw new Error(`渲染失败:\n${r.stderr || r.stdout}`); + return ok(r.stdout); + }, + + async grade_apply(a) { + // 护栏:spec 的键必须都在 schema 的 properties 里(schema 校验已做,这里再兜一层) + const specFile = path.join(a.outDir, '_spec.json'); + fs.mkdirSync(a.outDir, { recursive: true }); + fs.writeFileSync(specFile, JSON.stringify(a.spec, null, 2), 'utf8'); + const py = PYTHON(); + const args = [jyaiTool('jyai-grade-render.py'), + '--src', a.src, '--outdir', a.outDir, '--spec', specFile, + '--frames', String(a.frames ?? 12)]; + if (a.noLut) args.push('--no-lut'); + const r = await run(py, args); + if (r.code !== 0) throw new Error(`调色失败:\n${r.stderr || r.stdout}`); + // 护栏:逐节点生效探针(节点为 0 ⇒ 判失败) + const cmp = path.join(a.outDir, 'grade-compare.json'); + if (!fs.existsSync(cmp)) throw new Error('没出 grade-compare.json ⇒ 无法判生效'); + const j = JSON.parse(fs.readFileSync(cmp, 'utf8')); + const probe = j.node_effect_probe || {}; + const dead = Object.entries(probe).filter(([k, v]) => k !== '_full_chain' && !(Number(v) > 0)).map(([k]) => k); + if (dead.length) { + // ★ 2026-10-22 审计:探针为 0 有两种可能,错误信息必须分开说 + // ① 源画面几乎全黑 ⇒ 任何"加亮/加对比"类调色都被 clip 回 0 ⇒ 探针测不出(链是好的) + // ② 参数形态不对 ⇒ 节点真的没生效 + const srcLuma = Number(j.src_luma_max); + if (Number.isFinite(srcLuma) && srcLuma < 8.0) { + throw new Error(`★ 节点 ${dead.join(', ')} 的探针为 0 —— 但源画面几乎全黑(YAVG=${srcLuma.toFixed(2)}/255)` + + `⇒ 这是【源没有可测信息】,不是节点没生效(加亮类调色会被 clip 回 0)。换一个有大面积中间调/亮部的源再验。`); + } + throw new Error(`★ 下列节点没有生效(探针为 0):${dead.join(', ')} —— 判定失败` + + (Number.isFinite(srcLuma) ? `(源画面 YAVG=${srcLuma.toFixed(2)}/255)` : '')); + } + if (!(Number(j.grade_change_mad) > 0)) throw new Error('★ 调色改变量为 0 ⇒ 判定失败'); + return ok({ out: j.out10, outLut: j.out10_lut, gradeChangeMad: j.grade_change_mad, probe }); + }, + + async audio_mix(a) { + const r = await run(process.execPath, [jyaiTool('jyai-audio-mix.mjs'), + '--draft', a.draft, '--video', a.video, '--out', a.out]); + if (r.code !== 0) throw new Error(`混音失败:\n${r.stderr || r.stdout}`); + return ok(r.stdout); + }, + + // ★ 一条命令出成片:官方渲染器出画面(自带强度型调色)→ 可选我们的 3D LUT(曲线类) + // → 我们的混音链(音量/淡入淡出/降噪/响度归一)→ ffmpeg 编码 → 带断言的验收。 + // 官方导出档(ExportService / mojo / 官方编码器)不在我们的可达面上(§91.3 / §92.2), + // 所以出片走这条自制链;它拿到的画面就是官方渲染器的输出,不是我们自己算的。 + async make_film(a) { + const args = [jyaiTool('jyai-make-film.mjs'), '--draft', a.draft, '--out', a.outDir]; + if (a.lut) args.push('--lut', a.lut); + if (a.grade) { + // ★ 我们自己给曲线类调色:spec → 烘 3D LUT → 叠进出片 + // (不依赖官方草稿侧的曲线承载 —— 官方预览渲染器本来就不认曲线/HSL/色轮) + fs.mkdirSync(a.outDir, { recursive: true }); + const specFile = path.join(a.outDir, '_grade-spec.json'); + fs.writeFileSync(specFile, JSON.stringify({ spec: a.grade }, null, 2), 'utf8'); + const cube = path.join(a.outDir, 'grade.cube'); + const b = await run(PYTHON(), [jyaiTool('jyai-gl-bake.py'), '--params', specFile, '--out', cube]); + if (b.code !== 0) throw new Error(`烘 3D LUT 失败:\n${b.stderr || b.stdout}`); + args.push('--lut', cube); + } + if (a.denoise) args.push('--denoise', a.denoise); + if (a.beautify) args.push('--beautify', a.beautify); + if (a.fps) args.push('--fps', String(a.fps)); + if (a.maxFrames) args.push('--max-frames', String(a.maxFrames)); + if (a.targetLufs !== undefined) args.push('--target-lufs', String(a.targetLufs)); + const r = await run(process.execPath, args); + if (r.code !== 0) throw new Error(`出片失败:\n${r.stderr || r.stdout}`); + return ok(r.stdout); + }, + + async run_pipeline(a) { + // ★ 命令级编排器:按 references/pipeline-profiles.json 里的路线逐步执行 + // ★ 路线定义是【单一真源】(在 JSON 里)⇒ 改路线不用改这里 + // ★ 三种步骤:动词步骤 / 脚本步骤(scripts/ 下的脚本)/ 确认点(跑完就停下来等人) + // ★ ESM 没有 __dirname ⇒ 用 import.meta.url 解析 + const here = path.dirname(fileURLToPath(import.meta.url)); + const defFile = path.join(here, '..', 'references', 'pipeline-profiles.json'); + let defs; + try { defs = JSON.parse(fs.readFileSync(defFile, 'utf8')); } + catch (e) { throw new Error(`读不到路线定义:${defFile} —— ${e.message}`); } + const prof = (defs.profiles || []).find((p) => p.id === a.profile); + if (!prof) { + throw new Error(`未知路线 "${a.profile}";可用:${(defs.profiles || []).map((p) => p.id).join(', ')}`); + } + + // ───────────────────────── 续跑(两段式的第二段)───────────────────────── + if (a.resume) { + const rq = a.resume || {}; + if (!rq.token) throw new Error('resume 缺少 token(第一次调用返回的 resume.token)'); + const workDir = pipelineWorkDir(rq.token); + const st = loadPipeState(workDir); + if (!st) throw new Error(`找不到流水线状态(token ${rq.token})—— 可能已被清理`); + if (st.profile !== prof.id) throw new Error(`token 属于路线 "${st.profile}",不是 "${prof.id}"`); + if (!st.gate) throw new Error('这条流水线没有待确认的关卡(不需要续跑)'); + // ★ 没有 confirm ⇒ 【一步都不往下跑】:这是"关卡真的会拦人"的证据,不是口头承诺 + if (rq.confirm !== true) { + return ok({ + profile: st.profile, when: prof.when, steps: st.steps || [], failed: false, + verdict: 'REFUSED', gate: st.gate, + note: `没有 confirm=true ⇒ 【一步都没往下跑】:关卡后面的 ${st.gate.pendingSteps ?? '?'} 步全都没执行。`, + }); + } + // ★ 产物有没有被人改过?改过要说出来(不静默吞掉用户的编辑) + const changed = !!(st.gate.artifact && shaFile(st.gate.artifact) !== st.gate.artifactSha); + const vars = st.vars || {}; + const r = await execSteps(prof, vars, workDir, st.stepNo || 0, st.steps || []); + if (r.failed) { + savePipeState(workDir, { ...st, vars, steps: r.steps, failed: true, gate: null }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: true, verdict: 'FAIL', + artifactChangedSinceGate: changed, + note: `路线 "${prof.id}" 在第 ${r.failedStep} 步停下:${String(r.error).slice(0, 300)}`, + }); + } + if (r.gate) { + const gate = { ...r.gate, artifactChangedSinceGate: changed }; + savePipeState(workDir, { ...st, stepNo: r.gate.step, vars, steps: r.steps, gate }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: false, verdict: 'AWAIT_CONFIRM', + gate, resume: { token: st.token, profile: prof.id, confirm: true }, + artifactChangedSinceGate: changed, + note: `又到一个确认点(第 ${r.gate.step} 步)—— 继续等人确认。`, + }); + } + savePipeState(workDir, { ...st, vars, steps: r.steps, finished: true, gate: null }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: false, verdict: 'PASS', + outDir: vars.$outDir || null, resumedFrom: st.stepNo, artifactChangedSinceGate: changed, + }); + } + + // ───────────────────────── 新建一条流水线 ───────────────────────── + // ★ draft 不在 schema 的 required 里(续跑时不需要它)⇒ 这里补上条件必填 + if (!a.draft) throw new Error('缺少 draft(要编排的剪映草稿目录);续跑时用 resume.token 即可'); + const vars0 = { + $draft: a.draft, $outDir: a.outDir, $grade: a.grade, $srt: a.srt, + $segmentId: a.segmentId, $fps: a.fps, $denoise: a.denoise, + $targetLufs: a.targetLufs, $beautify: a.beautify, + // ★ 第二份草稿(两版对照审片用)⇒ 没有它就不给,不是空串 + ...(a.draftAfter ? { $draftAfter: a.draftAfter } : {}), + }; + // ★ 预检:整条路线还缺哪些输入 ⇒ 【一次性点名】(别跑到第 3 步才说"缺 $srt") + const missing = missingInputsOf(prof, vars0); + if (missing.length) { + throw new Error(`路线 "${prof.id}" 还缺 ${missing.length} 个必需输入:\n` + + missing.map((m) => ` · ${m.name}(${m.desc})—— ${m.from === 'user' ? '★ 必须由用户提供' : '由调用方给'}${m.note ? ';' + m.note : ''}`).join('\n') + + '\n⇒ 补齐后再跑;缺什么就问什么,不要拿默认值糊过去。'); + } + const vars = vars0; + const token = crypto.randomBytes(8).toString('hex'); + const workDir = pipelineWorkDir(token); + // ★ 产物路径由编排器分配(放在流水线工作目录里 ⇒ 关卡前后都能用) + for (const [vn, fname] of Object.entries(prof.alloc || {})) { + vars[vn] = path.join(workDir, fname); + } + const r = await execSteps(prof, vars, workDir, 0, []); + if (r.failed) { + savePipeState(workDir, { token, profile: prof.id, stepNo: r.failedStep, vars, steps: r.steps, failed: true, createdAt: new Date().toISOString() }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: true, verdict: 'FAIL', + note: `路线 "${prof.id}" 在第 ${r.failedStep} 步停下:${String(r.error).slice(0, 300)}`, + }); + } + if (r.gate) { + savePipeState(workDir, { token, profile: prof.id, stepNo: r.gate.step, vars, steps: r.steps, gate: r.gate, createdAt: new Date().toISOString() }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: false, verdict: 'AWAIT_CONFIRM', + gate: r.gate, + resume: { token, profile: prof.id, confirm: true }, + note: `路线 "${prof.id}" 在第 ${r.gate.step} 步停下等人确认(后面还有 ${r.gate.pendingSteps} 步没跑)。` + + '确认后用 run_pipeline({ profile: ' + prof.id + ', resume: { token: <上面 resume 里的那个>, confirm: true } }) 续跑。', + }); + } + savePipeState(workDir, { token, profile: prof.id, stepNo: prof.steps.length, vars, steps: r.steps, finished: true, createdAt: new Date().toISOString() }); + return ok({ + profile: prof.id, when: prof.when, steps: r.steps, failed: false, verdict: 'PASS', + outDir: a.outDir || null, + // ★ 这条路线【产出了什么】:只报字符串/数字型变量(路径与 id)⇒ 报告里能直接看到成片在哪 + vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => typeof v === 'string' || typeof v === 'number')), + }); + }, + + async verify_diff(a) { + // ★ max-over-frames:判据视野必须覆盖被测对象(不抽单帧) + // ★ 多维度审片(visibility / picture / audio / timeline / draft_integrity) + const checks = (a.checks && a.checks.length) ? a.checks : ['visibility']; + const want = (k) => checks.includes(k); + // ★★ 单版审片(2026-10-24):没有【改前】也能审这一版("审一下这版") + // 单版模式下:① 画面维度【不判"改动是否可见"】(没有改前就无从比)⇒ 报 skipped 并给出噪声底 + // ② picture 只给【绝对值】且【不判失败】(片子里本来就有的纯白/纯黑也算在内 ⇒ 不能当缺陷) + // ③ audio 测这一版的响度(绝对目标 −14 LUFS 可判)④ timeline 只给这一版的结构 + // ⇒ ★ 每一项都写清"判了什么、没判什么",不许用没判的维度凑一个"通过" + const single = !a.beforeDir; + // ★ 条件必填:查画面维度时,【改后】帧目录必须有;【改前】没有 ⇒ 走单版模式(不抛错) + if ((want('visibility') || want('picture')) && !a.afterDir) { + throw new Error('查 visibility/picture 时必须给 afterDir(改后帧目录);' + + '没有改前(beforeDir)也能审——但画面维度只能给噪声底与绝对值,不能判"改动可见"'); + } + const res = { checks: {}, requested: checks }; + const problems = []; + + // ---------- ① 画面:visibility + picture(同一趟解码里一起算)---------- + if (want('visibility') || want('picture')) { + const list = (d) => (d && fs.existsSync(d)) + ? fs.readdirSync(d).filter((f) => f.endsWith('.png')).sort() : []; + const B = single ? [] : list(a.beforeDir); + const A = list(a.afterDir); + const N = a.noiseDir ? list(a.noiseDir) : []; + if (!A.length) throw new Error('帧目录为空或不存在(afterDir)'); + if (!single && !B.length) throw new Error('改前帧目录为空或不存在(beforeDir)'); + if (a.noiseDir && !N.length) throw new Error('noiseDir 为空或不存在'); + // ★ 单版:噪声底就是【同一状态再渲一次】⇒ 拿 afterDir 跟 noiseDir 比 + const n = single ? Math.min(A.length, N.length || A.length) : Math.min(B.length, A.length); + const PNG = await importPng(); + let best = { mad: -1, frame: -1, maxAbs: 0, changed: 0 }; + let noiseBest = { mad: -1, frame: -1 }; + let changedFrames = 0, noiseChangedFrames = 0; + // ★ 逐帧噪声(2026-10-21 实测:非确定性是【逐帧】的,不是全局的) + const perFrameNoise = []; + const perFrameMad = []; + // picture 统计(after 侧全局;before 侧同步累计,用来算【增量】) + const sum = [0, 0, 0]; + const mn = [255, 255, 255]; + const mx = [0, 0, 0]; + let clipPx = 0, overPx = 0, underPx = 0, px = 0; + let bClipPx = 0, bOverPx = 0, bUnderPx = 0, bPx = 0; + const beforeSum = [0, 0, 0]; let beforePx = 0; + for (let i = 0; i < n; i++) { + // ★ 单版模式:没有改前帧 ⇒ x = null(后面所有"改动"量都跳过,只留噪声底) + const x = single ? null : await PNG.read(path.join(a.beforeDir, B[i])); + const y = await PNG.read(path.join(a.afterDir, A[i])); + if (x && x.length !== y.length) continue; + if (x) { + let dsum = 0, dmx = 0, ch = 0; + for (let k = 0; k < x.length; k++) { + const d = Math.abs(x[k] - y[k]); + dsum += d; if (d > dmx) dmx = d; if (d > 0) ch++; + } + const mad = dsum / x.length; + perFrameMad[i] = mad; + if (mad > 0) changedFrames++; + if (mad > best.mad) best = { mad, frame: i, maxAbs: dmx, changed: ch }; + } + // ★ 噪声底:同一状态【再渲染一次】的帧 ⇒ 同态差异就是渲染器噪声 + // (单版模式:参照物是【这一版】的帧 —— 同样是"同一状态渲两遍") + if (N.length) { + const z = await PNG.read(path.join(a.noiseDir, N[i])); + const ref = x || y; + if (z.length === ref.length) { + let nsum = 0; + for (let k = 0; k < ref.length; k++) nsum += Math.abs(ref[k] - z[k]); + const nmad = nsum / ref.length; + perFrameNoise[i] = nmad; + if (nmad > 0) noiseChangedFrames++; + if (nmad > noiseBest.mad) noiseBest = { mad: nmad, frame: i }; + } + } + if (want('picture')) { + for (let k = 0; k + 2 < y.length; k += 3) { + const r = y[k], g = y[k + 1], b = y[k + 2]; + sum[0] += r; sum[1] += g; sum[2] += b; + if (r < mn[0]) mn[0] = r; if (r > mx[0]) mx[0] = r; + if (g < mn[1]) mn[1] = g; if (g > mx[1]) mx[1] = g; + if (b < mn[2]) mn[2] = b; if (b > mx[2]) mx[2] = b; + const hi = Math.max(r, g, b), lo = Math.min(r, g, b); + if (hi >= 250) { clipPx++; overPx++; } else if (lo <= 5) underPx++; + px++; + } + // ★ 单版模式没有改前帧 ⇒ 改前侧的统计全部跳过("新增"本来就算不出来) + if (x) for (let k = 0; k + 2 < x.length; k += 3) { + const r = x[k], g = x[k + 1], b = x[k + 2]; + beforeSum[0] += r; beforeSum[1] += g; beforeSum[2] += b; + const hi = Math.max(r, g, b), lo = Math.min(r, g, b); + if (hi >= 250) { bClipPx++; bOverPx++; } else if (lo <= 5) bUnderPx++; + beforePx++; + } + } + } + const th = a.threshold ?? 0.5; + if (want('visibility') && single) { + // ★★ 单版审片:没有改前 ⇒ "改动是否可见"判不了 ⇒ 明确报 skipped, + // 但把【噪声底】给出来(同一状态渲两遍的差异)—— 这是审片最该先知道的一个数 + const noiseMad = noiseBest.mad < 0 ? null : noiseBest.mad; + res.checks.visibility = { + skipped: '单版审片:没有改前帧(beforeDir)⇒ 无法判"改动是否可见"' + + '(这一版没有"改动"可比)。要判改动可见,请给 beforeDir + noiseDir。', + noiseMad, noiseFrame: noiseBest.frame, noiseChangedFrames, totalFrames: n, + noiseK: a.noiseK ?? 1.5, + note: noiseMad === null + ? '没给 noiseDir ⇒ 噪声底未知;官方渲染器同态重渲染噪声实测可达 MAD 123.55 ⇒ 任何像素结论都要先量底' + : `同态重渲染的噪声底 = MAD ${noiseMad.toFixed(4)}(${noiseChangedFrames}/${n} 帧有差异)` + + ' ⇒ 任何"改动可见"的结论都要超过这个底', + }; + } else if (want('visibility')) { + // ★ 噪声校准:官方渲染器【同态重渲染】就有噪声(实测 17/108 帧变、最差 MAD 123.55) + // ⇒ 裸 MAD > 0.5 毫无判别力。给了 noiseDir 就按噪声底抬高判据线。 + const noiseMad = noiseBest.mad < 0 ? null : noiseBest.mad; + const noiseK = a.noiseK ?? 1.5; + const calibrated = noiseMad !== null; + // ★★★ 逐帧判据线(2026-10-21 §82 修正) + // 实测:官方预览渲染的【非确定性是逐帧的】——同一草稿渲两遍, + // 整片 MAD 123.55,但同一片里另一些帧只差 0.5; + // 而且【连着抓两次同一时刻】像素就不同 ⇒ 是"每次抓帧"层面的非确定, + // 不是"重渲染"层面的。官方 GUI 里也是这套引擎(连续播放时人眼察觉不到), + // 但逐像素静态比对会把它放大。 + // ⇒ 判据线必须【逐帧用自己的噪声】来定,而不是用全局最大噪声: + // bar_i = max(threshold, noise_i × noiseK) + // 这样"那一帧本来就分不出"(noise_i 很大 ⇒ bar_i 更大 ⇒ 不算可见), + // 而其它稳定帧的真实改动照常被检出。 + const frameDetail = []; + for (let i = 0; i < n; i++) { + const mad = perFrameMad[i]; + if (mad === undefined) continue; + const nm = perFrameNoise[i]; + const barI = calibrated && nm !== undefined ? Math.max(th, nm * noiseK) : th; + frameDetail.push({ + frame: i, mad, noise: nm === undefined ? null : nm, bar: barI, + unstable: calibrated && nm !== undefined && nm > th, + visible: mad > barI, + }); + } + const visibleRows = frameDetail.filter((r) => r.visible); + const unstable = frameDetail.filter((r) => r.unstable).map((r) => r.frame); + const bestVisible = visibleRows.length + // ★ 累加器别叫 a:handler 形参也叫 a ⇒ 同名会让"入参扫描"把 a.mad 当成入参(审计踩过) + ? visibleRows.reduce((acc, row) => (row.mad > acc.mad ? row : acc)) + : null; + const bar = calibrated ? Math.max(th, noiseMad * noiseK) : th; // 全局参考线(仅作参考) + const visible = visibleRows.length > 0; + const v = { maxMad: best.mad, frame: best.frame, maxAbs: best.maxAbs, + changedPixels: best.changed, changedFrames, totalFrames: n, threshold: th, + noiseMad, noiseFrame: noiseBest.frame, noiseChangedFrames, noiseK, + calibrated, noiseBar: bar, + unstableFrames: unstable, + unstableFrameCount: unstable.length, + visibleMaxMad: bestVisible ? bestVisible.mad : null, + visibleFrame: bestVisible ? bestVisible.frame : null, + visibleFrameCount: visibleRows.length, + frameDetail, + madOverNoise: noiseMad ? +(best.mad / noiseMad).toFixed(3) : null }; + v.verdict = visible ? 'PASS(改动可见)' : 'FAIL(改动在画面上不可见)'; + if (!calibrated) { + v.note = '未校准:官方渲染器同态重渲染噪声实测可达 MAD 123.55(逐帧分布,17/108 帧变)⇒ 裸 MAD 不能单独证明"改动可见";请补 noiseDir'; + } + res.checks.visibility = v; + if (!visible) { + const shown = bestVisible ? bestVisible.mad : (calibrated ? 0 : best.mad); + problems.push(`画面不可见:${calibrated ? '逐帧判据线下没有一帧达标' : `max MAD ${shown.toFixed(4)} ≤ 阈值 ${th}`}` + + (calibrated ? `(全局噪声底 ${noiseMad.toFixed(4)} × ${noiseK};不稳定帧 ${unstable.length}/${n} 帧:噪声本身就超过阈值 ⇒ 这些帧分不出改动)` : '(未校准,阈值仅 0.5)')); + } + } + if (want('picture') && px > 0) { + const mean = sum.map((s) => s / px); + const bmean = beforePx ? beforeSum.map((s) => s / beforePx) : [0, 0, 0]; + const p = { + afterMean: mean.map((v) => +v.toFixed(4)), + beforeMean: bmean.map((v) => +v.toFixed(4)), + meanDrift: mean.map((v, k) => +(v - bmean[k]).toFixed(4)), + min: mn, max: mx, + clipRatio: +(clipPx / px).toFixed(6), + overRatio: +(overPx / px).toFixed(6), + underRatio: +(underPx / px).toFixed(6), + pixels: px, + }; + // ★ 判据用【增量】而不是绝对值:片子里本来就有的纯白/纯黑不算"削波/死黑", + // 只有"被改动新推上去/压下去"的才算缺陷。 + if (beforePx) { + p.beforeClipRatio = +(bClipPx / beforePx).toFixed(6); + p.beforeOverRatio = +(bOverPx / beforePx).toFixed(6); + p.beforeUnderRatio = +(bUnderPx / beforePx).toFixed(6); + p.clipDelta = +(p.clipRatio - p.beforeClipRatio).toFixed(6); + p.underDelta = +(p.underRatio - p.beforeUnderRatio).toFixed(6); + } + const clipDelta = p.clipDelta ?? 0; + const underDelta = p.underDelta ?? 0; + if (beforePx) { + if (clipDelta > 0.02) problems.push(`高光削波【新增】${(clipDelta * 100).toFixed(2)}%(>2%)`); + if (underDelta > 0.10) problems.push(`暗部死黑【新增】${(underDelta * 100).toFixed(2)}%(>10%)`); + } else { + // ★ 单版:算不出"新增" ⇒ 只报绝对值,且【不判失败】 + // (片子里本来就有的纯白/纯黑不算缺陷 ⇒ 绝对值不能当判据,只能当"待确认") + p.note = '单版审片:这是【绝对值】(片子里本来就有的纯白/纯黑也算在内)' + + '⇒ 不能当缺陷判据;判"新增削波/死黑"需要改前帧(beforeDir)'; + p.verdict = '参考(未判定)'; + } + res.checks.picture = p; + } + } + + // ---------- ② 音频:LUFS / true peak ---------- + if (want('audio')) { + if (!a.afterVideo) { + res.checks.audio = { skipped: '需要 afterVideo(mp4 路径)才能测响度' }; + } else { + const meas = async (mp) => { + const r = await run(FFMPEG(), ['-hide_banner', '-nostats', '-i', mp, + '-af', 'ebur128=peak=true', '-f', 'null', '-']); + const s = r.stderr || ''; + const I = (s.match(/^\s*I:\s*(-?[\d.]+)\s*LUFS/m) || [])[1]; + const Pk = (s.match(/^\s*Peak:\s*(-?[\d.]+)\s*dBFS/m) || [])[1]; + return { integratedLufs: I === undefined ? null : +I, truePeakDbfs: Pk === undefined ? null : +Pk }; + }; + const c = await meas(a.afterVideo); + const res2 = { after: c }; + // ★ 单版:没有改前视频也能测响度(绝对目标 −14 LUFS ⇒ 可判),只是没有增量可比 + if (a.beforeVideo) { + res2.before = await meas(a.beforeVideo); + // ★ 改前也测不出来 ⇒ 同样点名(不能拿"改前没音轨"当成"响度没变") + if (res2.before.integratedLufs === null) { + res2.undeterminedReason = (res2.undeterminedReason ? res2.undeterminedReason + ';' : '') + + '改前那版没有可测的音频 ⇒ 响度【无法比对】(不是"响度没变")'; + } + } else res2.note = '单版审片:没有改前视频 ⇒ 只测了【这一版】的响度(没有比增量)'; + // ★★ 测不出来 ≠ "响度正常":成片里没有音频流时必须点名"未判定" + if (c.integratedLufs === null) { + res2.undeterminedReason = '这一版的成片里没有可测的音频(无音轨 / 纯画面片)' + + ' ⇒ 响度【未判定】(不是"响度正常")'; + } else if (Math.abs(c.integratedLufs - (-14)) > 0.5) { + // ★ 判的是【偏离 −14 LUFS 的量】,不是响度值本身 + // (原来写成 Math.abs(lufs) > 0.5 ⇒ 任何正常归一的 −14 LUFS 都会被说成"未归一", + // 报告里自相矛盾:"integratedLufs: -14" + "响度未归一到 −14 LUFS") + res2.note = (res2.note ? res2.note + ';' : '') + + `响度偏离 −14 LUFS ${(c.integratedLufs + 14).toFixed(2)} LU(>0.5)` + + '(见 jianying-audio-mix)'; + } + res.checks.audio = res2; + } + } + + // ---------- ③ 时间线:段数 / 轨道 / 时长 ---------- + if (want('timeline')) { + if (!a.afterDraft) { + res.checks.timeline = { skipped: '需要 afterDraft(草稿 JSON 路径)' }; + } else if (!a.beforeDraft) { + // ★ 单版审片:只给【这一版】的结构(不判"变了什么") + const da0 = readDraftJson(a.afterDraft); + if (!da0) res.checks.timeline = { skipped: '草稿读不出来(可能是官方加密封装)' }; + else { + res.checks.timeline = { + after: draftShape(da0), single: true, + note: '单版审片:没有改前草稿 ⇒ 只给了【这一版】的结构(段数/轨道/时长);' + + '要判"改了什么",需要改前草稿(beforeDraft)', + }; + } + } else { + const db = readDraftJson(a.beforeDraft), da = readDraftJson(a.afterDraft); + if (!db || !da) res.checks.timeline = { skipped: '草稿读不出来(可能是官方加密封装)' }; + else { + const sb = draftShape(db), sa = draftShape(da); + // ★ 逐段字段差:语义证据("我到底改了什么"),不依赖像素 + // ★ 段级字段差一律走共用实现(交集比较 + morphOnly 单列) + // 旧实现在这里【又算了一遍全字段比较】⇒ 把"形态补全"当成了"值变了" ⇒ 语义判据假绿 + const segmentDiffs = segmentDiffOf(db, da); + const t = { before: sb, after: sa, + segmentsDelta: sa.segments - sb.segments, + durationDeltaUs: sa.durationUs - sb.durationUs, + segmentDiffs }; + // ★ 给了 expect 就按 expect 断言;没给才用"什么都没动 ⇒ 可疑"的启发式 + const exp = a.expect || null; + if (exp) { + if (exp.segmentsDelta !== undefined && t.segmentsDelta !== exp.segmentsDelta) { + problems.push(`段数变化 ${t.segmentsDelta} ≠ 期望 ${exp.segmentsDelta}`); + } + if (exp.durationDeltaUs !== undefined && t.durationDeltaUs !== exp.durationDeltaUs) { + problems.push(`时长变化 ${t.durationDeltaUs} µs ≠ 期望 ${exp.durationDeltaUs} µs`); + } + if (exp.segmentFieldChanged) { + const hit = segmentDiffs.some((d) => d.fields && d.fields[exp.segmentFieldChanged]); + // ★ 若该字段只在【运行时展开形态】里存在,而基线是【精简形态】⇒ 不是"没变", + // 而是【判不出来】(判据的视野不够)—— 两者的结论必须分开说 + const morphHit = segmentDiffs.some((d) => (d.morphOnly || []).includes(exp.segmentFieldChanged)); + if (!hit) { + if (morphHit) { + problems.push(`"${exp.segmentFieldChanged}" 只在【运行时展开形态】里存在,而基线是【精简形态】` + + '⇒ 这一项【无法判定】(不是"没变")。请让改前/改后都是同一形态再判。'); + } else { + problems.push(`没有任何 segment 的 "${exp.segmentFieldChanged}" 字段发生变化(改动可能没落盘)`); + } + } + } + if (exp.segmentFieldUnchanged) { + const hit = segmentDiffs.some((d) => d.fields && d.fields[exp.segmentFieldUnchanged]); + if (hit) { + problems.push(`不该变的 "${exp.segmentFieldUnchanged}" 字段却变了`); + } else { + // ★ "没变"必须区分"【无法判定】":字段压根不在交集里 ⇒ 判据的视野不够 + const cmp = fieldComparability(db, da, exp.segmentFieldUnchanged); + if (cmp.comparable === 0) { + problems.push(cmp.oneSided > 0 + ? `"${exp.segmentFieldUnchanged}" 只在【一边】的形态里存在(改前/改后形态不同)` + + '⇒ 这一项【无法判定】(不是"没变")。请让改前/改后都是同一形态再判。' + : `"${exp.segmentFieldUnchanged}" 在改前/改后都不存在 ⇒ 这一项【无法判定】(不是"没变")`); + } + } + } + } else if (t.segmentsDelta === 0 && t.durationDeltaUs === 0 && segmentDiffs.length === 0) { + problems.push('时间线没有任何变化(段数、时长、段字段都没动)—— 改动可能没落盘'); + } + res.checks.timeline = t; + } + } + } + + // ---------- ④ 草稿完整性:真草稿 sha ---------- + if (want('draft_integrity')) { + if (!a.realDraft) { + res.checks.draft_integrity = { skipped: '需要 realDraft(真草稿目录)' }; + } else { + const t = shaTree(a.realDraft); + res.checks.draft_integrity = { files: t.length, sha: t.slice(0, 3), note: '落盘时已做前后逐字节断言' }; + } + } + + res.verdict = problems.length ? 'FAIL' : 'PASS'; + res.problems = problems; + // ★★ 收集【未判定的维度】(skipped)—— 纪律:没测的维度不能算"通过" + // (官方 editing-review 的原话:没有足够证据的必查项要保留为"待确认", + // 不能因为"没发现问题"就给完整通过) + // 单版审片时尤其重要:visibility 本来就判不了 ⇒ 不能让 PASS 看起来像"全查过了" + const undetermined = []; + for (const [k, v] of Object.entries(res.checks)) { + // ★ skipped(这个维度没查)与 undeterminedReason(查了但测不出来)都要点名 + if (v && typeof v === 'object' && (v.skipped || v.undeterminedReason)) { + undetermined.push({ check: k, why: v.skipped || v.undeterminedReason }); + } + } + if (undetermined.length) { + res.undetermined = undetermined; + res.verdictNote = `有 ${undetermined.length} 个维度【未判定】(${undetermined.map((u) => u.check).join('、')})` + + ' ⇒ 这不是"完整通过";未测的维度要写成"待确认",不能写成"正常"'; + } + // ★ isError=true 仍保留(skill 语义:判不过就是判不过),但加 failureKind 让调用方能区分 + // 「验收结论不通过」与「工具故障」⇒ 后者走 .catch 的 `xxx 失败:…` 文本,没有 failureKind + if (problems.length) res.failureKind = 'acceptance'; + return problems.length ? fail(res) : ok(res); + }, +}; + +/* PNG 读:优先用 pngjs(若有),否则回落到 ffmpeg 解码 */ +let PNG = null; +async function importPng() { + if (PNG) return PNG; + const ff = FFMPEG(); + PNG = { + async read(file) { + const r = await run(ff, ['-v', 'error', '-i', file, '-pix_fmt', 'rgb24', '-f', 'rawvideo', '-'], + { cwd: path.dirname(file), binary: true }); + if (r.code !== 0 || !r.stdout.length) throw new Error(`解 PNG 失败: ${file}\n${r.stderr}`); + return r.stdout; + }, + }; + return PNG; +} + +/* ------------------------------------------------------------ 工具装载 */ +function loadTools() { + const out = []; + for (const f of fs.readdirSync(TOOLS_DIR).filter((x) => x.endsWith('.schema.json')).sort()) { + const s = JSON.parse(fs.readFileSync(path.join(TOOLS_DIR, f), 'utf8')); + out.push({ name: s.name, description: s.description, inputSchema: s.inputSchema, file: f }); + } + return out; +} +let TOOLLIST = []; +function initTools() { + TOOLLIST = loadTools(); + for (const t of TOOLLIST) { + if (typeof IMPL[t.name] !== 'function') { + console.error(`[jianying] 警告:schema ${t.file} 的工具 "${t.name}" 没有实现`); + } + } +} + +/* ------------------------------------------------------------ MCP 协议 */ +function handle(message) { + if (message.method === 'initialize') { + return { result: { protocolVersion: message.params?.protocolVersion ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'jianying', version: '0.1.0' } } }; + } + if (message.method === 'tools/list') { + return { result: { tools: TOOLLIST.map((t) => ({ + name: t.name, description: t.description, inputSchema: t.inputSchema })) } }; + } + if (message.method === 'tools/call') { + const name = message.params?.name; + const t = TOOLLIST.find((x) => x.name === name); + if (!t) return { error: { code: -32601, message: `未知工具: ${name}` } }; + let args = {}; + try { + args = validate(message.params?.arguments || {}, t.inputSchema); + validateCrossVerb(name, args); + } catch (e) { + return { result: fail(String(e.message)) }; + } + return Promise.resolve(IMPL[name](args)) + .then((r) => ({ result: r })) + .catch((e) => ({ result: fail(`${name} 失败:${e.message}`) })); + } + return { error: { code: -32601, message: `Method not found: ${String(message.method)}` } }; +} + +initTools(); +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { msg = JSON.parse(line); } catch { return; } + if (msg.id === undefined) return; + const out = handle(msg); + if (out instanceof Promise) { + out.then((r) => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, ...r })}\n`)); + } else { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, ...out })}\n`); + } +}); diff --git a/plugins/antianqi/jianying/mcp/tools/audio-mix.schema.json b/plugins/antianqi/jianying/mcp/tools/audio-mix.schema.json new file mode 100644 index 00000000..02e70c65 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/audio-mix.schema.json @@ -0,0 +1,27 @@ +{ + "name": "audio_mix", + "description": "按草稿的音频结构重做混音并与画面合流(官方语义 + EBU R128 归一到 −14 LUFS)。", + "inputSchema": { + "type": "object", + "properties": { + "draft": { + "type": "string", + "description": "草稿目录" + }, + "video": { + "type": "string", + "description": "画面 mp4(无声或待替换音轨)" + }, + "out": { + "type": "string", + "description": "输出 mp4" + } + }, + "required": [ + "draft", + "video", + "out" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/mcp/tools/draft-commit.schema.json b/plugins/antianqi/jianying/mcp/tools/draft-commit.schema.json new file mode 100644 index 00000000..399cdb51 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/draft-commit.schema.json @@ -0,0 +1,19 @@ +{ + "name": "draft_commit", + "description": "把当前 session 的改动落盘到草稿。会做【素材路径重定位】并断言真草稿 sha 未变。改动来源是 draft_patch 存下的快照(动词之间通过插件状态目录共享状态)。", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "draft_open 返回的 sessionId" + }, + "realDraft": { + "type": "string", + "description": "真草稿目录(只读)。用于落盘前后 sha 逐字节断言——真草稿被改动就判失败。" + } + }, + "required": ["sessionId"], + "additionalProperties": false + } +} diff --git a/plugins/antianqi/jianying/mcp/tools/draft-open.schema.json b/plugins/antianqi/jianying/mcp/tools/draft-open.schema.json new file mode 100644 index 00000000..d343b167 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/draft-open.schema.json @@ -0,0 +1,27 @@ +{ + "name": "draft_open", + "description": "打开一个剪映草稿(默认只读副本)。返回 sessionId 与草稿信息。绝不改真草稿。", + "inputSchema": { + "type": "object", + "properties": { + "draft": { + "type": "string", + "description": "草稿目录路径或草稿名" + }, + "copy": { + "type": "boolean", + "default": true, + "description": "默认 true=开副本。false=开真草稿(只读用途,且需 allowReal)" + }, + "allowReal": { + "type": "boolean", + "default": false, + "description": "允许开真草稿。默认 false;true 时若 copy 仍为 true 则拒绝" + } + }, + "required": [ + "draft" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/mcp/tools/draft-patch.schema.json b/plugins/antianqi/jianying/mcp/tools/draft-patch.schema.json new file mode 100644 index 00000000..35cd45c3 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/draft-patch.schema.json @@ -0,0 +1,246 @@ +{ + "name": "draft_patch", + "description": "对草稿做语义级修改。官方 edit 域共 27 个带命名空间的行为操作(实测 27/27 可用):draft.setCanvasSize/setDraftId · track.add/delete/mute · segment.move/scale/translate/rotate/updateLayout/split/delete/setTimeRange/innerReplace/setDescription · video.add/muteOn/muteOff/setVolume · audio.add/setVolume · text.add · undoRedo.undo/redo/canUndo/canRedo · invokeBatch。先调 draft_open 拿 sessionId。", + "inputSchema": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "adjustment.add", + "adjustment.resetBasicColor", + "adjustment.setBasicColor", + "attachment.deleteAiWriterInfos", + "attachment.deleteAsyncTasks", + "attachment.deleteVideoAlgorithms", + "attachment.insertAiWriterInfos", + "attachment.insertAsyncTasks", + "attachment.insertVideoAlgorithms", + "attachment.setAiCreatorContent", + "attachment.updateAiCreation", + "attachment.updateAiReel", + "attachment.updateAiReelAgent", + "attachment.updateAiWriterInfo", + "attachment.updateAsyncTasksStatus", + "attachment.updateVideoAlgorithm", + "audio.add", + "audio.addBeat", + "audio.clearVoiceEffect", + "audio.denoiseOff", + "audio.denoiseOn", + "audio.modifiedToneOff", + "audio.modifiedToneOn", + "audio.pitchOff", + "audio.pitchOn", + "audio.removeBeat", + "audio.removeVocalBeautify", + "audio.removeVocalSeparation", + "audio.resetVocalBeautify", + "audio.setFade", + "audio.setMaterial", + "audio.setSpeed", + "audio.setVocalBeautify", + "audio.setVocalSeparation", + "audio.setVoiceEffect", + "audio.setVoiceEffectParams", + "audio.setVoiceEffectToAll", + "audio.setVolume", + "audio.updateVocalBeautify", + "audio.updateVocalSeparation", + "batch.batchEnd", + "batch.batchStart", + "batch.freezeVideo", + "draft.clearCover", + "draft.clearDraft", + "draft.ensureDraftCanvasSize", + "draft.setAlignMode", + "draft.setCanvasBackground", + "draft.setCanvasSize", + "draft.setCover", + "draft.setDraft", + "draft.setDraftId", + "draft.setDraftKeywordsConfigs", + "draft.setDraftName", + "draft.setDraftUpdateTime", + "draft.setVideoGeneratorType", + "draft.updateDraftAIGCWatermark", + "effect.add", + "effect.resetAdjustParams", + "effect.updateAdjustParams", + "filter.add", + "filter.update", + "keyframe.add", + "keyframe.delete", + "keyframe.update", + "segment.delete", + "segment.duplicate", + "segment.innerReplace", + "segment.innerReplaceStack", + "segment.insert", + "segment.mirror", + "segment.move", + "segment.multiSegmentsSetTimeRange", + "segment.mute", + "segment.paste", + "segment.rotate", + "segment.scale", + "segment.setDescription", + "segment.setName", + "segment.setTimeRange", + "segment.split", + "segment.translate", + "segment.updateLayout", + "segment.visible", + "sticker.add", + "sticker.addAnimation", + "sticker.addVideoTracker", + "sticker.deleteAnimation", + "sticker.removeVideoTracker", + "sticker.updateAnimation", + "sticker.updateVideoTracker", + "story.addAiStoryAlternativeMaterials", + "story.cloneAiStoryAlternativeMaterials", + "story.deleteAiStoryAlternative", + "story.deleteAiStoryAlternativeMaterials", + "story.findInnerReplaceIds", + "story.innerReplace", + "story.innerReplaceStack", + "story.pinAiStoryAlternativeMaterial", + "story.updateAiStoryAlternativeMaterial", + "story.updateMoveFeedsToAlternative", + "story.updateStoryId", + "template.setMutableAudios", + "template.setMutableTexts", + "template.setMutableVideos", + "template.setTemplateMute", + "templateCombination.replace", + "templateCombination.setSize", + "templateCombination.templateCombinationAdd", + "text.add", + "text.addAnimation", + "text.addSubtitles", + "text.addVideoTracker", + "text.clearSubtitleKeywords", + "text.deleteAnimation", + "text.deleteTextToAudioIds", + "text.removeVideoTracker", + "text.resetSubtitleTemplate", + "text.setFixedSize", + "text.setMaterials", + "text.setSubtitleKeywords", + "text.updateAnimation", + "text.updateEffect", + "text.updateSubtitleTemplate", + "text.updateVideoTracker", + "textTemplate.add", + "textTemplate.addAnimation", + "textTemplate.addVideoTracker", + "textTemplate.deleteAnimation", + "textTemplate.removeVideoTracker", + "textTemplate.setMaterial", + "textTemplate.updateAnimation", + "textTemplate.updateVideoTracker", + "track.add", + "track.delete", + "track.mute", + "track.updateTrackName", + "undoRedo.canRedo", + "undoRedo.canUndo", + "undoRedo.getTopItemId", + "undoRedo.redo", + "undoRedo.resetUndoRedo", + "undoRedo.undo", + "video.add", + "video.addAnimation", + "video.addDigitalHuman", + "video.addObjectLocked", + "video.addSmartCrop", + "video.applyAllChroma", + "video.clearBackground", + "video.clearFigure", + "video.clearMask", + "video.clearMatting", + "video.clearTransition", + "video.crop", + "video.deleteAnimation", + "video.denoiseOff", + "video.denoiseOn", + "video.formatVideo", + "video.muteOff", + "video.muteOn", + "video.pitchOff", + "video.pitchOn", + "video.removeObjectLocked", + "video.removeSmartCrop", + "video.resetBasicColor", + "video.resetChroma", + "video.reverse", + "video.seprateAudio", + "video.setBackgroundBlur", + "video.setBackgroundColor", + "video.setBackgroundImage", + "video.setBasicColor", + "video.setChromaColor", + "video.setChromaEdgeSmooth", + "video.setChromaIntensity", + "video.setChromaShadow", + "video.setChromaSpill", + "video.setCurveSpeed", + "video.setFade", + "video.setFigure", + "video.setFigureFaceId", + "video.setMakeupRoot", + "video.setMask", + "video.setMaterial", + "video.setMatting", + "video.setMix", + "video.setOpacity", + "video.setSmartColorAdjust", + "video.setSpeed", + "video.setStable", + "video.setTransition", + "video.setVideoAlgorithm", + "video.setVolume", + "video.updateAnimation", + "video.updateDigitalHuman", + "video.updateDigitalHumanRendered", + "video.updateObjectLocked", + "video.updateTransition" + ], + "description": "官方行为操作名(带命名空间,不是裸名)。全集 18 域 / 199 个,桥实测全部放行;全量清单与已知参数契约见 references/official-ops.md。" + }, + "sessionId": { + "type": "string", + "description": "draft_open 返回的 sessionId" + }, + "params": { + "type": "object", + "description": "该 op 的参数(官方原生语义)。已知必填:video.add→path · text.add→content · track.mute/delete→trackId · segment.move/setTimeRange/setDescription→segmentId · segment.updateLayout→layout · segment.innerReplace→replacement。缺必填会先被本地拦下,不会白跑一次官方调用。" + }, + "expect": { + "type": "object", + "description": "★ 可选的生效断言:要求这次改动【真的落到草稿里】。官方返回 accepted 不代表真生效(实测:官方 audio.add 会 accepted 且 changedPaths=[\"/\"],但草稿里 materials.audios 仍是 0 条)。给了 expect 却不成立 ⇒ 直接判失败。", + "properties": { + "segmentFieldChanged": { + "type": "string", + "description": "要求某个段的这个字段发生了变化(如 volume)" + }, + "segmentFieldUnchanged": { + "type": "string", + "description": "要求某个段的这个字段【没有】变化" + }, + "segmentsDelta": { + "type": "integer", + "description": "要求段数变化等于此值(如加一条音频 ⇒ 1)" + } + }, + "additionalProperties": false + } + }, + "required": [ + "op", + "sessionId" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/mcp/tools/draft-read.schema.json b/plugins/antianqi/jianying/mcp/tools/draft-read.schema.json new file mode 100644 index 00000000..2317dee5 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/draft-read.schema.json @@ -0,0 +1,23 @@ +{ + "name": "draft_read", + "description": "读取草稿的语义结构(时间线 / 片段 / 素材),返回 JSON + 结构化摘要。给 sessionId 时读【会话内存当前状态】(即 draft_patch 改过、还没落盘的那份);不给则读磁盘上的草稿。", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "draft_content.json 路径(明文)。不传 sessionId 时用它;不传则读当前 session 的草稿" + }, + "sessionId": { + "type": "string", + "description": "draft_open 返回的 sessionId。给了就读【会话内存】当前状态(draft_patch 改过但未落盘的改动也看得到)" + }, + "root": { + "type": "string", + "description": "草稿根目录(素材/子目录的解析基准)。不传则取 path 的上级目录。★ 官方 readFile 没有它会解析不出 workspace ⇒ 静默返回空" + } + }, + "additionalProperties": false, + "description": "(scope 分段读取当前【未实现】:后端不支持,schema 已移除该参数,别按它写代码)" + } +} diff --git a/plugins/antianqi/jianying/mcp/tools/grade-apply.schema.json b/plugins/antianqi/jianying/mcp/tools/grade-apply.schema.json new file mode 100644 index 00000000..6db6cf48 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/grade-apply.schema.json @@ -0,0 +1,693 @@ +{ + "name": "grade_apply", + "description": "用我们逐像素复刻的调色链(10 个节点,对齐官方 shader,误差 ≤1 LSB)给片子调色,出 10bit 成片(保留原音轨)。自带逐节点生效探针:任一节点单独打开无变化 ⇒ 判失败。", + "inputSchema": { + "type": "object", + "properties": { + "src": { + "type": "string", + "description": "源片路径" + }, + "outDir": { + "type": "string", + "description": "输出目录" + }, + "spec": { + "type": "object", + "description": "调色 spec:键是节点名,值是该节点的参数。未知键会被拒绝(防静默失效)。", + "properties": { + "adjustColor": { + "type": "object", + "description": "基础画面调节(官方 adjustColor)。fade/lightSensation 是它的子参数,不是独立节点。", + "properties": { + "brightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "contrast": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "vibrance": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "highlight": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "shadow": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "black": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "white": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "temperature": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "fade": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "lightSensation": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "colorCorrection": { + "type": "object", + "description": "色彩校正(官方 colorCorrection,与 adjustColor 同源的精简变体)。", + "properties": { + "contrast": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "brightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "highlight": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "shadow": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "black": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "white": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "temperature": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "hsl": { + "type": "object", + "description": "分色调整(官方 HSL)。键是颜色名,值是 hue/saturation/lightness(界面值 ±100)。", + "properties": { + "HSLRed": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "HSLGreen": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "HSLBlue": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "HSLYellow": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "HSLCyan": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "HSLMagenta": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true, + "patternProperties": { + "^[0-9]{2}#[0-9a-fA-F]{6}_[HSL]$": { + "type": "object", + "properties": { + "hue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "saturation": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lightness": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + } + } + }, + "logWheel": { + "type": "object", + "description": "对数轮(四段:阴影/中间调/高光/偏移)。", + "properties": { + "shadow": { + "type": "object", + "properties": { + "r": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "g": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "b": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "s": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "midtone": { + "type": "object", + "properties": { + "r": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "g": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "b": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "s": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "highlights": { + "type": "object", + "properties": { + "r": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "g": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "b": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "s": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "offset": { + "type": "object", + "properties": { + "r": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "g": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "b": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "s": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "rngDown": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "rngUp": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "primaryWheel": { + "type": "object", + "description": "三原色轮(官方 primaryWheel)。", + "properties": { + "red": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "cyan": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "magenta": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "yellow": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "curves": { + "type": "object", + "description": "曲线(官方 curves,4 条 1D 曲线 R/G/B/Y 一起施加)。控制点用官方 [A,L,R] 布局:每控制点 = 3 个 vec2(锚点 A / 两个出向柄 L、R)⇒ 6 个数/控制点。键名:RPoints/GPoints/BPoints/YPoints(官方名)或 red/green/blue/luma(链 API 别名,等价)。★ 缺任一条通道会【直接报错】(不再静默烘成恒等 LUT)。★ 官方语义:Y(亮度)曲线会等量改 R/G/B 三个通道(为保色相)⇒ 只改 R 时 G/B 也会小幅变化。", + "properties": { + "RPoints": { + "type": "array", + "items": { + "type": "number" + }, + "description": "红通道控制点(官方 [A,L,R] 布局,6 个数/控制点)" + }, + "GPoints": { + "type": "array", + "items": { + "type": "number" + }, + "description": "绿通道控制点" + }, + "BPoints": { + "type": "array", + "items": { + "type": "number" + }, + "description": "蓝通道控制点" + }, + "YPoints": { + "type": "array", + "items": { + "type": "number" + }, + "description": "亮度通道控制点" + }, + "red": { + "type": "array", + "items": { + "type": "number" + }, + "description": "别名(= RPoints)" + }, + "green": { + "type": "array", + "items": { + "type": "number" + }, + "description": "别名(= GPoints)" + }, + "blue": { + "type": "array", + "items": { + "type": "number" + }, + "description": "别名(= BPoints)" + }, + "luma": { + "type": "array", + "items": { + "type": "number" + }, + "description": "别名(= YPoints)" + }, + "intensityR": { + "type": "number", + "description": "红通道强度 0..1" + }, + "intensityG": { + "type": "number", + "description": "绿通道强度 0..1" + }, + "intensityB": { + "type": "number", + "description": "蓝通道强度 0..1" + }, + "intensityY": { + "type": "number", + "description": "亮度通道强度 0..1" + } + }, + "additionalProperties": false + }, + "hsl_curves": { + "type": "object", + "description": "HSL 耦合曲线(6 条)。points 是官方 L,A,R 扁平坐标(与 curves 的 A,L,R 不同!)。", + "properties": { + "hue2hue": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, + "hue2sat": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, + "hue2lum": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, + "lum2sat": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, + "satsat": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, + "satlum": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "points" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "sharpen": { + "type": "object", + "description": "锐化(渲染期算子,邻域依赖,不能烘 3D LUT)。", + "properties": { + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "vignetting": { + "type": "object", + "description": "暗角/白角(渲染期算子,uv 依赖,不能烘 3D LUT)。", + "properties": { + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "param": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + "particle": { + "type": "object", + "description": "颗粒/光斑(渲染期算子,uv 依赖)。", + "properties": { + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "param": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "seed": { + "type": "integer" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "frames": { + "type": "integer", + "default": 12, + "description": "处理前 N 帧" + }, + "noLut": { + "type": "boolean", + "default": false, + "description": "只出逐点直算那一版" + } + }, + "required": [ + "src", + "outDir", + "spec" + ], + "additionalProperties": false + } +} diff --git a/plugins/antianqi/jianying/mcp/tools/make-film.schema.json b/plugins/antianqi/jianying/mcp/tools/make-film.schema.json new file mode 100644 index 00000000..56230612 --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/make-film.schema.json @@ -0,0 +1,105 @@ +{ + "name": "make_film", + "description": "一条命令出成片:官方渲染器出画面(★ 官方预览渲染器自带强度型调色)+ 可选我们的 3D LUT(曲线类调色,官方预览不认)+ 我们的混音链(音量/淡入淡出/降噪/响度归一)+ ffmpeg 编码,并做带断言的验收(双流齐备 / 时长 / 响度偏离 ≤1 LUFS 直接判失败)。", + "inputSchema": { + "type": "object", + "properties": { + "draft": { + "type": "string", + "description": "草稿目录" + }, + "outDir": { + "type": "string", + "description": "输出目录(成片落在 /film.mp4)" + }, + "lut": { + "type": "string", + "description": "可选:3D LUT 文件(.cube)—— 用于曲线类调色(curves / HSL / 色轮),官方预览渲染器不认这些,必须我们自己烘 LUT 再叠" + }, + "denoise": { + "type": "string", + "enum": [ + "auto", + "off" + ], + "default": "auto", + "description": "降噪:auto = 草稿里有 realtime_denoises 就按强度施加(ffmpeg afftdn 近似,★ 不是剪映的 SAMI denoise_v2);off = 不施加" + }, + "beautify": { + "type": "string", + "enum": [ + "off", + "approx" + ], + "default": "off", + "description": "人声美化:off = 不做(默认);approx = 近似 EQ+压缩(★ 剪映的真实算法不可得)" + }, + "fps": { + "type": "number", + "default": 12 + }, + "maxFrames": { + "type": "number", + "default": 0, + "description": "调试用:只渲染前 N 帧。★ 截帧会让成片时长短于官方时间线 ⇒ 内建验收会判\"时长对不齐\"并失败。" + }, + "targetLufs": { + "type": "number", + "default": -14, + "description": "响度归一目标(LUFS)。验收会断言实际值与之相差 ≤1 LUFS" + }, + "grade": { + "type": "object", + "properties": { + "adjustColor": { + "type": "object", + "description": "基础画面调节(亮度/对比/饱和/鲜艳/高光/暗部/黑白/色温…)" + }, + "colorCorrection": { + "type": "object", + "description": "色彩校正" + }, + "hsl": { + "type": "object", + "description": "分色调整(HSLRed / 01#ad8a86 这类取色键)" + }, + "logWheel": { + "type": "object", + "description": "对数轮(shadow/midtone/highlights/offset)" + }, + "primaryWheel": { + "type": "object", + "description": "三原色轮" + }, + "curves": { + "type": "object", + "description": "曲线(R/G/B/Y 四条 1D,官方 [A,L,R] 布局,每控制点 6 个数;缺任一条会直接报错)" + }, + "hsl_curves": { + "type": "object", + "description": "HSL 耦合曲线(6 条,官方 L,A,R 布局)" + }, + "sharpen": { + "type": "object", + "description": "锐化(渲染期算子,3D LUT 表达不了)⇒ 写进来会明确报错并指向 grade_apply" + }, + "vignetting": { + "type": "object", + "description": "暗角/白角(渲染期算子,3D LUT 表达不了)⇒ 同上" + }, + "particle": { + "type": "object", + "description": "颗粒/光斑(渲染期算子,3D LUT 表达不了)⇒ 同上" + } + }, + "description": "调色 spec(链 API 形态,见 references/chain-api.md)。★ 只支持【逐点函数类】节点(adjustColor/colorCorrection/hsl/logWheel/primaryWheel/curves/hsl_curves)—— 它们能烘成 3D LUT。★ 渲染期算子(sharpen/vignetting/particle,依赖邻域/uv0)3D LUT 表达不了:写进来会【直接报错】,请改用 grade_apply 动词(逐点直算路径,支持全部节点)。", + "additionalProperties": false + } + }, + "required": [ + "draft", + "outDir" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/mcp/tools/render-frames.schema.json b/plugins/antianqi/jianying/mcp/tools/render-frames.schema.json new file mode 100644 index 00000000..dd54da5f --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/render-frames.schema.json @@ -0,0 +1,26 @@ +{ + "name": "render_frames", + "description": "用官方渲染器把草稿渲成帧序列 / mp4(画面来自官方渲染器,不是我们自己算)。", + "inputSchema": { + "type": "object", + "properties": { + "draft": { + "type": "string", + "description": "草稿目录" + }, + "outDir": { + "type": "string", + "description": "输出目录" + }, + "fps": { + "type": "number", + "default": 12 + } + }, + "required": [ + "draft", + "outDir" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/mcp/tools/run-pipeline.schema.json b/plugins/antianqi/jianying/mcp/tools/run-pipeline.schema.json new file mode 100644 index 00000000..5126fcbe --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/run-pipeline.schema.json @@ -0,0 +1,91 @@ +{ + "name": "run_pipeline", + "description": "按预置路线编排多个动词(开副本 → 改草稿 → 落盘 → 语义验收 → 出成片)。路线定义在 references/pipeline-profiles.json(单一真源)。任一步失败会立刻停下并报告停在哪一步(宁可报错不许装成功)。★ 编排器跑的是官方渲染器 ⇒ 用之前剪映里得打开着这个草稿。", + "inputSchema": { + "type": "object", + "properties": { + "profile": { + "type": "string", + "enum": [ + "grade-and-render", + "edit-and-verify", + "edit-then-render", + "talk-cut-then-render", + "subtitle-then-render", + "render-and-mix", + "review-only", + "review-compare", + "grade-chain-then-render" + ], + "description": "要走的路线:调色并出片 / 改草稿并语义验收 / 改完直接出片 / 剪口播再出片 / 加字幕并出片 / 出片并按草稿重做混音 / 审这一版(只读不改) / 两版对照审片(只读不改) / 曲线类调色并出片" + }, + "draftAfter": { + "type": "string", + "description": "★ 第二份草稿目录(改后那一版)—— 只有【两版对照审片】路线要它(review-compare);不给就用 $draft" + }, + "draft": { + "type": "string", + "description": "要编排的剪映草稿目录(真草稿,插件只读它、写它的副本)★ 续跑时不用给(已经存在流水线状态里了)" + }, + "draftAfter": { + "type": "string", + "description": "★ 第二份草稿目录(改后那一版)—— 只有【两版对照审片】路线要它(review-compare);不给就用 $draft" + }, + "outDir": { + "type": "string", + "description": "成片输出目录" + }, + "grade": { + "type": "object", + "description": "调色 spec(形状同 grade_apply 的 spec;不给就不调色)" + }, + "segmentId": { + "type": "string", + "description": "要改的段 id(不给则取草稿里的第一个段)" + }, + "srt": { + "type": "string", + "description": "转写稿路径(talk-cut 路线用;★ 官方 ASR 拿不到,得由用户提供)" + }, + "fps": { + "type": "number", + "description": "出片帧率(默认官方时间线)" + }, + "denoise": { + "type": "string", + "description": "降噪:auto / off / light / strong" + }, + "targetLufs": { + "type": "number", + "description": "响度目标(默认 -14 LUFS)" + }, + "beautify": { + "type": "string", + "description": "人声美化(近似 EQ+压缩),如 approx" + }, + "resume": { + "type": "object", + "description": "续跑(两段式的第二段):把第一次调用返回的 resume 对象原样带回来。★ confirm 必须是 true 才会继续跑;不给 confirm ⇒ 编排器【一步都不往下跑】并回 REFUSED(确认点真的会拦人)。", + "properties": { + "token": { + "type": "string", + "description": "第一次调用返回的 resume.token(16 位小写十六进制)" + }, + "confirm": { + "type": "boolean", + "description": "用户已看过关卡产物并确认 ⇒ true 才会继续执行后面的步骤" + }, + "profile": { + "type": "string", + "description": "(可选)路线 id,用于校验 token 属于哪条路线" + } + }, + "required": ["token"] + } + }, + "required": [ + "profile" + ], + "additionalProperties": false + } +} diff --git a/plugins/antianqi/jianying/mcp/tools/verify-diff.schema.json b/plugins/antianqi/jianying/mcp/tools/verify-diff.schema.json new file mode 100644 index 00000000..2b525ede --- /dev/null +++ b/plugins/antianqi/jianying/mcp/tools/verify-diff.schema.json @@ -0,0 +1,92 @@ +{ + "name": "verify_diff", + "description": "审片/验收:改动前后对照。可查多个维度 —— visibility(画面变化是否可见,max-over-frames)、picture(画质:均值漂移/高光削波/暗部死黑)、audio(响度 LUFS 与 true peak)、timeline(段数/轨道/时长变化)、draft_integrity(真草稿 sha)。任一维度不达标即判 FAIL 并列出 problems。", + "inputSchema": { + "type": "object", + "properties": { + "beforeDir": { + "type": "string", + "description": "改前帧目录(PNG 序列)" + }, + "afterDir": { + "type": "string", + "description": "改后帧目录(PNG 序列)" + }, + "checks": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "visibility", + "picture", + "audio", + "timeline", + "draft_integrity" + ] + }, + "default": [ + "visibility" + ], + "description": "要查的维度。缺省只查 visibility。" + }, + "threshold": { + "type": "number", + "default": 0.5, + "description": "visibility 的 max MAD 阈值(0..255 标度)。★ 官方渲染器同态重渲染就有噪声(实测 17/108 帧变、最差 MAD 123.55)⇒ 建议同时给 noiseDir 做校准。" + }, + "noiseDir": { + "type": "string", + "description": "★ 噪声基线帧目录:同一状态【再渲染一次】的帧。给了就把判据线抬到 max(threshold, noiseMad×noiseK),不给则判据【未校准】(输出里会标 note)。" + }, + "noiseK": { + "type": "number", + "default": 1.5, + "description": "噪声底的倍数(默认 1.5)。★ 只在【给了 noiseDir】(噪声底校准)时参与判据线:判据线 = max(threshold, 噪声底 × noiseK);不给 noiseDir 时判据线就是 threshold,本参数无效。" + }, + "expect": { + "type": "object", + "description": "★ 期望断言(给 timeline 维度用)。给了就按期望判定,不再用\"什么都没动\"的启发式。", + "properties": { + "segmentsDelta": { + "type": "number", + "description": "期望的段数变化(如剪掉一段 = -1)" + }, + "durationDeltaUs": { + "type": "number", + "description": "期望的时长变化(µs)" + }, + "segmentFieldChanged": { + "type": "string", + "description": "期望某个 segment 字段发生变化(如 volume)" + }, + "segmentFieldUnchanged": { + "type": "string", + "description": "期望某个 segment 字段【不】变化" + } + }, + "additionalProperties": false + }, + "beforeVideo": { + "type": "string", + "description": "音频维度用:改前的 mp4(与 afterVideo 一起给)。不给 ⇒ 音频维度报 skipped(不判定)。" + }, + "afterVideo": { + "type": "string", + "description": "音频维度用:改后的 mp4(与 beforeVideo 一起给)。不给 ⇒ 音频维度报 skipped(不判定)。" + }, + "beforeDraft": { + "type": "string", + "description": "timeline 维度用:改前草稿 JSON 路径(明文)。★ 必须是【裸草稿】——顶层就有 tracks/segments;draft_read 的响应外面还包了一层 draft,直接存响应会读出 tracks:0" + }, + "afterDraft": { + "type": "string", + "description": "timeline 维度用:改后草稿 JSON 路径(明文)。★ 必须是【裸草稿】——顶层就有 tracks/segments;draft_read 的响应外面还包了一层 draft,直接存响应会读出 tracks:0" + }, + "realDraft": { + "type": "string", + "description": "draft_integrity 维度用:真草稿目录(只读)" + } + }, + "additionalProperties": false + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/plugin.json b/plugins/antianqi/jianying/plugin.json new file mode 100644 index 00000000..df16c053 --- /dev/null +++ b/plugins/antianqi/jianying/plugin.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "jianying", + "version": "0.2.0", + "description": "用逐像素复刻的剪映调色链给视频调色并出 10bit 成片(保留原音轨),附逐节点生效探针与画面级验收。10 个 MCP 动词覆盖草稿读写、调色、混音、渲染、验收、一条命令出成片,以及按预置路线编排多条动词(官方渲染器画面 + 我们的调色/混音 + ffmpeg 编码 + 带断言的验收)。", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "homepage": "https://github.com/antianqi/MiniMax-Code-Plugins-1/tree/main/plugins/antianqi/jianying", + "repository": "https://github.com/antianqi/MiniMax-Code-Plugins-1/tree/main/plugins/antianqi/jianying", + "license": "Apache-2.0", + "keywords": [ + "mcode", + "jianying", + "video-editing", + "color-grading", + "10bit", + "mcp" + ] +} diff --git a/plugins/antianqi/jianying/references/capability-dict.json b/plugins/antianqi/jianying/references/capability-dict.json new file mode 100644 index 00000000..fb488264 --- /dev/null +++ b/plugins/antianqi/jianying/references/capability-dict.json @@ -0,0 +1,353 @@ +{ + "schema": "jianying.capability-dict.v1", + "note": "9 个动词 / 3 类元素 / 3 类节点 / 6 类判据 的机器可读版;人读版见 references/chain-api.md。★ 官方 op 全集 18 域 199 个、桥全部放行(实测),全量见 references/official-ops.md", + "verbs": { + "draft_open": { + "kind": "official-semantics", + "ops": [], + "guards": [ + "默认开副本", + "非副本需 allowReal" + ] + }, + "draft_read": { + "kind": "official-semantics", + "ops": [ + "full" + ], + "guards": [ + "★ scope 分段读取【未实现】(后端不支持)⇒ schema 已移除该参数,传了会被拒而不是静默忽略" + ] + }, + "draft_patch": { + "kind": "official-semantics(全集 18 域 / 199 个 op,桥实测全部放行)", + "ops": [ + "adjustment.add", + "adjustment.resetBasicColor", + "adjustment.setBasicColor", + "attachment.deleteAiWriterInfos", + "attachment.deleteAsyncTasks", + "attachment.deleteVideoAlgorithms", + "attachment.insertAiWriterInfos", + "attachment.insertAsyncTasks", + "attachment.insertVideoAlgorithms", + "attachment.setAiCreatorContent", + "attachment.updateAiCreation", + "attachment.updateAiReel", + "attachment.updateAiReelAgent", + "attachment.updateAiWriterInfo", + "attachment.updateAsyncTasksStatus", + "attachment.updateVideoAlgorithm", + "audio.add", + "audio.addBeat", + "audio.clearVoiceEffect", + "audio.denoiseOff", + "audio.denoiseOn", + "audio.modifiedToneOff", + "audio.modifiedToneOn", + "audio.pitchOff", + "audio.pitchOn", + "audio.removeBeat", + "audio.removeVocalBeautify", + "audio.removeVocalSeparation", + "audio.resetVocalBeautify", + "audio.setFade", + "audio.setMaterial", + "audio.setSpeed", + "audio.setVocalBeautify", + "audio.setVocalSeparation", + "audio.setVoiceEffect", + "audio.setVoiceEffectParams", + "audio.setVoiceEffectToAll", + "audio.setVolume", + "audio.updateVocalBeautify", + "audio.updateVocalSeparation", + "batch.batchEnd", + "batch.batchStart", + "batch.freezeVideo", + "draft.clearCover", + "draft.clearDraft", + "draft.ensureDraftCanvasSize", + "draft.setAlignMode", + "draft.setCanvasBackground", + "draft.setCanvasSize", + "draft.setCover", + "draft.setDraft", + "draft.setDraftId", + "draft.setDraftKeywordsConfigs", + "draft.setDraftName", + "draft.setDraftUpdateTime", + "draft.setVideoGeneratorType", + "draft.updateDraftAIGCWatermark", + "effect.add", + "effect.resetAdjustParams", + "effect.updateAdjustParams", + "filter.add", + "filter.update", + "keyframe.add", + "keyframe.delete", + "keyframe.update", + "segment.delete", + "segment.duplicate", + "segment.innerReplace", + "segment.innerReplaceStack", + "segment.insert", + "segment.mirror", + "segment.move", + "segment.multiSegmentsSetTimeRange", + "segment.mute", + "segment.paste", + "segment.rotate", + "segment.scale", + "segment.setDescription", + "segment.setName", + "segment.setTimeRange", + "segment.split", + "segment.translate", + "segment.updateLayout", + "segment.visible", + "sticker.add", + "sticker.addAnimation", + "sticker.addVideoTracker", + "sticker.deleteAnimation", + "sticker.removeVideoTracker", + "sticker.updateAnimation", + "sticker.updateVideoTracker", + "story.addAiStoryAlternativeMaterials", + "story.cloneAiStoryAlternativeMaterials", + "story.deleteAiStoryAlternative", + "story.deleteAiStoryAlternativeMaterials", + "story.findInnerReplaceIds", + "story.innerReplace", + "story.innerReplaceStack", + "story.pinAiStoryAlternativeMaterial", + "story.updateAiStoryAlternativeMaterial", + "story.updateMoveFeedsToAlternative", + "story.updateStoryId", + "template.setMutableAudios", + "template.setMutableTexts", + "template.setMutableVideos", + "template.setTemplateMute", + "templateCombination.replace", + "templateCombination.setSize", + "templateCombination.templateCombinationAdd", + "text.add", + "text.addAnimation", + "text.addSubtitles", + "text.addVideoTracker", + "text.clearSubtitleKeywords", + "text.deleteAnimation", + "text.deleteTextToAudioIds", + "text.removeVideoTracker", + "text.resetSubtitleTemplate", + "text.setFixedSize", + "text.setMaterials", + "text.setSubtitleKeywords", + "text.updateAnimation", + "text.updateEffect", + "text.updateSubtitleTemplate", + "text.updateVideoTracker", + "textTemplate.add", + "textTemplate.addAnimation", + "textTemplate.addVideoTracker", + "textTemplate.deleteAnimation", + "textTemplate.removeVideoTracker", + "textTemplate.setMaterial", + "textTemplate.updateAnimation", + "textTemplate.updateVideoTracker", + "track.add", + "track.delete", + "track.mute", + "track.updateTrackName", + "undoRedo.canRedo", + "undoRedo.canUndo", + "undoRedo.getTopItemId", + "undoRedo.redo", + "undoRedo.resetUndoRedo", + "undoRedo.undo", + "video.add", + "video.addAnimation", + "video.addDigitalHuman", + "video.addObjectLocked", + "video.addSmartCrop", + "video.applyAllChroma", + "video.clearBackground", + "video.clearFigure", + "video.clearMask", + "video.clearMatting", + "video.clearTransition", + "video.crop", + "video.deleteAnimation", + "video.denoiseOff", + "video.denoiseOn", + "video.formatVideo", + "video.muteOff", + "video.muteOn", + "video.pitchOff", + "video.pitchOn", + "video.removeObjectLocked", + "video.removeSmartCrop", + "video.resetBasicColor", + "video.resetChroma", + "video.reverse", + "video.seprateAudio", + "video.setBackgroundBlur", + "video.setBackgroundColor", + "video.setBackgroundImage", + "video.setBasicColor", + "video.setChromaColor", + "video.setChromaEdgeSmooth", + "video.setChromaIntensity", + "video.setChromaShadow", + "video.setChromaSpill", + "video.setCurveSpeed", + "video.setFade", + "video.setFigure", + "video.setFigureFaceId", + "video.setMakeupRoot", + "video.setMask", + "video.setMaterial", + "video.setMatting", + "video.setMix", + "video.setOpacity", + "video.setSmartColorAdjust", + "video.setSpeed", + "video.setStable", + "video.setTransition", + "video.setVideoAlgorithm", + "video.setVolume", + "video.updateAnimation", + "video.updateDigitalHuman", + "video.updateDigitalHumanRendered", + "video.updateObjectLocked", + "video.updateTransition" + ], + "guards": [ + "改前必须先 draft_open", + "★ 全集 199 个 op 我们这道桥【全部放行】(0 个 unsupported)⇒ 以前\"只有 27 个\"是我们只试了 27 个", + "★ accepted:true ≠ 生效:判据必须是\"回读草稿后字段真的变了\"(segment.setTimeRange 传 startUs/durationUs ⇒ 官方回 accepted 但时码被清零)", + "★ text.add 的 needInsertTargetTrack=true 时段会插进 trackIndex 指向的那条轨(哪怕是 video 轨)⇒ 往同一条 text 轨塞多条字幕要用 needInsertTargetTrack=false + 该 text 轨下标", + "★ 视频/音频 AI 效果类 op 的契约(实测 2026-10-21):video.denoiseOn{segmentId,isDenoise?,denoiseMode?,denoiseRate?} ⇒ materials.realtime_denoises(引擎 sami_name=denoise_v2,本地实时 DSP);audio.setVocalBeautify{segmentId,enable:true} ⇒ materials.vocal_beautifys(本地实时);audio.setVocalSeparation{…} ⇒ materials.vocal_separations(production_path 为空 ⇒ 产出要剪映的 AI 任务,我们只写引用)", + "★ 视频/音频 AI 效果类 op 的契约(实测 2026-10-21):video.denoiseOn{segmentId,isDenoise?,denoiseMode?,denoiseRate?} ⇒ materials.realtime_denoises(引擎 sami_name=denoise_v2,本地实时 DSP);audio.setVocalBeautify{segmentId,enable:true} ⇒ materials.vocal_beautifys(本地实时);audio.setVocalSeparation{…} ⇒ materials.vocal_separations(production_path 为空 ⇒ 产出要剪映的 AI 任务,我们只写引用)", + "★ 我们的出片链【已接上降噪】(2026-10-22 端到端实测):混音器读草稿 materials.realtime_denoises → 按段 extra_material_refs 匹配 → ffmpeg afftdn(★ 近似,不是剪映的 SAMI denoise_v2)⇒ 成片文件内 SNR 5.646→12.128 dB(+6.482 dB),负向注入组 0.0000 dB;vocal_beautifys 仍是近似且默认关闭,vocal_separations 拿不到产出 ⇒ 对美化/分离而言『写进草稿 ≠ 出现在我们的成片里』依然成立" + ] + }, + "draft_commit": { + "kind": "official-semantics", + "ops": [], + "guards": [ + "素材路径重定位", + "真草稿 sha 前后比对" + ] + }, + "render_frames": { + "kind": "official-semantics", + "ops": [], + "guards": [] + }, + "grade_apply": { + "kind": "ours", + "ops": [ + "adjustColor", + "colorCorrection", + "curves", + "hsl", + "hsl_curves", + "logWheel", + "particle", + "primaryWheel", + "sharpen", + "vignetting" + ], + "guards": [ + "未知键直接拒绝", + "逐节点生效探针(节点为 0 ⇒ 失败)", + "★ 节点内的参数名拼错 ⇒ 过去被静默忽略、烘出恒等 LUT(出片\"成功\"但调色没发生)⇒ 现在 schema 与烘焙器两层都拒", + "★ primaryWheel / hsl 用户取色键:链 API 形态未接入 ⇒ 现在直接报错(不是静默恒等)" + ] + }, + "audio_mix": { + "kind": "ours", + "ops": [], + "guards": [ + "响度归一到 −14 LUFS" + ] + }, + "verify_diff": { + "kind": "ours", + "ops": [], + "guards": [ + "max-over-frames(不抽单帧)", + "真草稿 sha 断言" + ] + }, + "make_film": { + "kind": "ours", + "ops": [], + "guards": [ + "画面来自官方渲染器(player 域)⇒ ★ 官方预览渲染器自带强度型调色", + "可选 3D LUT(--lut):曲线类调色(官方预览不认 curves/HSL/色轮 ⇒ 我们自己烘 LUT 再叠)", + "音频走我们的混音链:volume / fade / 降噪(ffmpeg afftdn 近似,★ 非剪映 SAMI denoise_v2)/ loudnorm 归一", + "验收带断言:双流齐备 + 时长对齐官方时间线(容差 1 帧)+ 响度偏离 ≤1 LUFS ⇒ 否则判失败", + "草稿无可用音轨 ⇒ 出纯画面片并报出缺失素材数(不判失败)", + "★ 官方导出档(ExportService / mojo / 官方编码器)不在我们的可达面上 ⇒ 不走官方导出" + ] + } + }, + "capabilities": { + "element": { + "draft": "draft_read", + "main": "draft_read", + "multi": "draft_read" + }, + "segment": { + "video": "draft_patch#video.add", + "image": "draft_patch", + "text": "draft_patch", + "subtitle": "draft_patch#text.add{content,startTime,duration}", + "sound": "audio_mix", + "sticker": "draft_patch" + }, + "property": { + "transform": "draft_patch", + "time": "draft_patch", + "alpha": "draft_patch", + "content": "draft_patch", + "sub": "draft_patch", + "effect": "grade_apply#adjustColor|colorCorrection|hsl|logWheel|primaryWheel|curves|hsl_curves|sharpen|vignetting|particle", + "mask": "draft_patch", + "matting": "draft_patch", + "animation": "draft_patch", + "speed_curve": "draft_patch", + "audio_ai": "draft_patch#video.denoiseOn|audio.setVocalBeautify|audio.setVoiceEffect" + } + }, + "node_kinds": { + "bakeable_3dlut": [ + "adjustColor", + "colorCorrection", + "curves", + "hsl", + "hsl_curves", + "logWheel", + "primaryWheel" + ], + "render_time": [ + "particle", + "sharpen", + "vignetting" + ], + "sub_param_of_adjustColor": [ + "fade", + "lightSensation" + ] + }, + "acceptance": { + "grade_apply": "每个节点单开必须产生非零改变(MAD>0)", + "verify_diff": "画面维度:max MAD > max(threshold, noiseMad×1.5),且必须给 noiseDir(同态重渲染的帧)做噪声校准;官方渲染器同态噪声底实测 MAD 48~123 ⇒ 未校准的裸 MAD 只能作参考。语义维度:用 expect 断言落盘草稿的逐段字段差(不依赖像素)", + "draft_commit": "真草稿 sha 前后逐字节相同", + "draft_patch": "回读草稿后目标字段真的变了(不接受\"官方说 accepted\")", + "text.add": "字幕时码与期望吻合(容差 ±1 帧,官方按 fps 帧对齐)", + "audio_ai": "回读草稿:materials.realtime_denoises / vocal_beautifys / audio_effects 里有新条目且段上 extra_material_refs 变了(★ 这只证明\"写进草稿了\",不证明\"出现在我们的成片里\"——降噪已另外在混音链里实现并实测:成片文件内 SNR +6.482 dB、负向注入 0.0000 dB;美化/分离仍未实现)" + } +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/references/chain-api.md b/plugins/antianqi/jianying/references/chain-api.md new file mode 100644 index 00000000..de9b2143 --- /dev/null +++ b/plugins/antianqi/jianying/references/chain-api.md @@ -0,0 +1,136 @@ +# 链 API 人读版(`grade.apply` 的 `spec` 怎么填) + +> **机器可读版**:`mcp/tools/grade-apply.schema.json`(硬校验,非法键直接拒绝) +> **能力字典**:`references/capability-dict.json`(10 动词 / 3 类元素 / 3 类节点 / 6 类判据) +> ★ 本文的语义部分(每个参数**什么意思**)只有这里有;schema 管"能不能填"。 + +## 强度语义 + +所有 `-1..1` 的参数是**强度**,不是绝对值:`0` = 不动 · `+0.5` = 中等往上调 · `-1` = 拉满。 +`0..1` 的参数是**比例/百分比**(如 `sharpen.intensity`、`vignetting.param`)。 + +## 10 个节点 + +| 节点 | 类型 | 能否烘 3D LUT | 官方对应 | +|---|---|---|---| +| `adjustColor` | 逐点 | ✅ | 基础画面调节(亮度/对比/饱和/高光/暗部/色温…) | +| `colorCorrection` | 逐点 | ✅ | 色彩校正(adjustColor 的精简变体) | +| `hsl` | 逐点 | ✅ | 分色调整(8 默认色 + 用户取色) | +| `logWheel` | 逐点 | ✅ | 对数轮(阴影/中间调/高光/偏移 四段) | +| `primaryWheel` | 逐点 | ✅ | 三原色轮 ★ **链 API 形态未接入**(`red/green/blue/cyan/magenta/yellow` 各自的参数语义从未定义)⇒ 写进来**直接报错** | +| `curves` | 逐点 | ✅ | 曲线(red/green/blue/luma 4 条 1D) | +| `hsl_curves` | 逐点 | ✅ | HSL 耦合曲线(6 条) | +| `sharpen` | **邻域** | ❌ | 锐化(渲染期算子) | +| `vignetting` | **uv** | ❌ | 暗角/白角(渲染期算子) | +| `particle` | **uv** | ❌ | 颗粒/光斑(渲染期算子) | + +★ **邻域 / uv 依赖的三个节点不能烘 3D LUT**(3D LUT 只能表达"逐点函数")⇒ 只能在渲染期算。 + +### 哪个动词用哪条链(★ 别混用) + +| 节点 | `grade_apply`(逐点直算) | `make_film --grade`(3D LUT) | +|---|---|---| +| `adjustColor` `colorCorrection` `hsl` `logWheel` `primaryWheel` `curves` `hsl_curves` | ✅ | ✅ | +| `sharpen` `vignetting` `particle`(渲染期算子:邻域 / uv0 依赖) | ✅ | ❌ **写进来直接报错**(3D LUT 表达不了 ⇒ 会静默变恒等) | +| `smartColorAdjustment` | ❌ 未实现(会报错) | ❌ 会报错 | + +⇒ ★ **两条链都不许"静默失效"**: +- 3D LUT 路径:缺曲线通道 / 含渲染期算子 / 含未实现节点 ⇒ **抛异常**,不烘恒等 +- 逐点直算路径:带**逐节点生效探针**(单节点 vs 零,必须非零),并输出 "★ 无效果(参数形态可能不对)" +- **节点内的参数名**:拼错 ⇒ 过去被静默忽略、烘出**恒等 LUT**(出片"成功"但调色没发生) + ⇒ 现在**两层都堵**:schema 用 `grade_apply` 的 spec 约束(跨动词复用),烘焙器自己也拒未知参数名 + +### `adjustColor`(含两个"子参数",不是独立节点) + +| 参数 | 含义 | +|---|---| +| `brightness` | 整体亮度 | +| `contrast` | 对比度 | +| `saturation` | 饱和度 | +| `vibrance` | 自然饱和度(保护已高饱和区域) | +| `highlight` | 高光(提亮区的对比) | +| `shadow` | 暗部 | +| `black` / `white` | 黑场 / 白场 | +| `temperature` | 色温(**正 = 偏暖**,负 = 偏冷) | +| `fade` | 褪色(0..1)—— 走官方 `fade_max.texture` 17³ LUT | +| `lightSensation` | 光感(−1..1)—— 走官方 min/max 两张 17³ LUT | + +★ **`fade` / `lightSensation` 是 `adjustColor` 的子参数**(官方 frag 里是 adjustColor 流水线的最后两段,顺序固定)。 + +### `hsl`(分色调整) + +键 = 颜色名,值 = `{hue, saturation, lightness}`(界面值 ±100): +`HSLRed` / `HSLGreen` / `HSLBlue` / `HSLYellow` / `HSLCyan` / `HSLMagenta`, +以及**用户取色**(形如 `01#ad8a86_H` 的键,schema 里用 `patternProperties` 放行)。 + +> ★ **用户取色键尚未接入链 API 形态**(官方要按取色器给它分配色相范围,见 `HSL_LEFT/RIGHT_KEY_PTS` 那套) +> ⇒ 过去会被**静默丢弃**(该颜色完全不生效,看起来像"没反应")⇒ 现在**直接报错**,请改用官方默认色。 + +### `logWheel`(对数轮,四段) + +每段 = `{r, g, b, s}`(色相/饱和,−1..1)。段名:`shadow` / `midtone` / `highlights` / `offset`。 + +### `curves`(曲线)—— ★ 控制点是 **A,L,R** 布局,**每控制点 3 个 vec2(= 6 个数)** + +`red` / `green` / `blue` / `luma` 各一组控制点,**扁平数字数组**。 + +> ★★ **最常踩的坑**:控制点**不是 x,y 交替**!**每控制点 = 3 个 vec2 = [锚点 A, 出向柄 L, 出向柄 R]** +> ⇒ **6 个数/控制点**。例:24 个数 = 4 个控制点。 +> +> ★ 官方键名 `RPoints` / `GPoints` / `BPoints` / `YPoints` 与 `red` / `green` / `blue` / `luma` +> **完全等价**(烘焙器两套都认,实测烘出的 LUT 相同)。 +> +> ★ **R / G / B / Y 四条必须一起给** —— 缺任一条会**直接报错**(不再静默烘成恒等 LUT)。 +> +> ★ 可选强度键:`intensityR` / `intensityG` / `intensityB` / `intensityY`(0..1,缺省 1)。 +> +> ★ 官方语义:**Y(亮度)曲线会等量改 R/G/B 三个通道**(为保色相)⇒ +> 只改 R 时 G/B 也会小幅变化,**这不是 bug**。 + +```json +"curves": { + "red": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "green": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "blue": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "luma": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1] +} +``` + +### `hsl_curves`(HSL 耦合曲线)—— ★ 控制点是 **L,A,R** 布局 + +6 条:`hue2hue` / `hue2sat` / `hue2lum` / `lum2sat` / `satsat` / `satlum`, +每条 = `{points: [x0,y0, x1,y1, ...]}`。 + +> **★★ 最容易踩的坑:`curves` 的控制点是 A,L,R,`hsl_curves` 的是 L,A,R —— 两套布局不能互相套用。** +> 判别:`L`(Left 控制点)永远在 `A`(锚点)之前 ⇒ 看第一个三元组的第二项是 x 还是 y。 + +## 怎么写一个 spec(例子) + +```json +{ + "adjustColor": { "brightness": 0.10, "contrast": 0.12, "saturation": 0.28, "temperature": 0.15 }, + "logWheel": { "shadow": { "r": 0.14, "g": 0.06, "b": 0.0, "s": 0.08 } }, + "sharpen": { "intensity": 0.45 } +} +``` + +## 精度(我们复刻的,不是官方保证) + +| 路径 | 误差 | +|---|---| +| 逐点直算(`--no-lut` 之外的默认) | maxAbs **1 LSB** | +| 65³ 3D LUT | maxAbs **2 LSB**(链式插值误差) | +| 129³ 3D LUT | maxAbs **2 LSB**,但更小 | + +⇒ **要精确 ⇒ 用逐点直算那一版**(`graded10.mp4`);要小体积/快 ⇒ 用 LUT 版。 + +## 已知不覆盖 + +- `fade` / `lightSensation` 只在 `adjustColor` 内有效,单独提出来无效(会被 schema 拒) +- `primaryWheel`(三原色轮)**尚未接入链 API 形态** —— 参数语义未定义,过去会静默烘成恒等 LUT ⇒ 现在直接报错 +- `hsl` 的**用户取色键**(`01#ad8a86_H`)**尚未接入链 API 形态** ⇒ 现在直接报错(请用官方默认色) +- `clear`(清晰度,8 级金字塔)**尚未接入链**(官方默认 intensity=0 本身是空转) +- `colorMigration`(色彩迁移)官方算法在闭源 `Amaz.Algorithm` ⇒ 只能自算替代 +- 曲线 / HSL / 色轮**写回剪映**的草稿字段名尚未确证 ⇒ 目前只能**我们本地出片**,不能写进剪映预览 + ⇒ 且**实测官方预览渲染器本身也不认这些**(强度型调色它就带,曲线/HSL/色轮/effect 链/forceRender 链全不认) + ⇒ 所以"调色写回剪映预览"这条路对曲线类**是关的**,曲线类只能走我们自己的出片链。 diff --git a/plugins/antianqi/jianying/references/intent-map.json b/plugins/antianqi/jianying/references/intent-map.json new file mode 100644 index 00000000..00bdc9cf --- /dev/null +++ b/plugins/antianqi/jianying/references/intent-map.json @@ -0,0 +1,309 @@ +{ + "schema": "jianying.intent-map.v1", + "note": "自然语言诉求 → 动词/参数 的【机器可读】映射。各 skill 正文里那张\"常见诉求\"人读表是本文件的展开(★ 两者必须一致,由机械审计 7f/7g 双向校验)。★ 本文件里的参数名由机械审计按 mcp/tools/grade-apply.schema.json 校验 ⇒ 拼错会被当场抓出来。", + "generated_from": [ + "jianying-cut: 7 条", + "jianying-grade-chain: 6 条", + "jianying-draft-edit: 14 条" + ], + "counts": { + "raw": 27, + "merged": 21 + }, + "intents": [ + { + "utterance": "调亮一点", + "verb": "grade_apply", + "params": { + "spec": { + "adjustColor": { + "brightness": 0.12 + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "调暖一点", + "verb": "grade_apply", + "params": { + "spec": { + "adjustColor": { + "temperature": 0.15 + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "提亮中间调、压高光", + "verb": "grade_apply", + "params": { + "spec": { + "adjustColor": { + "shadow": 0.15, + "highlight": -0.1 + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "更有食欲(餐饮)", + "verb": "grade_apply", + "params": { + "spec": { + "adjustColor": { + "saturation": 0.28, + "temperature": 0.15, + "contrast": 0.12 + }, + "logWheel": { + "shadow": { + "r": 0.14, + "g": 0.06, + "b": 0, + "s": 0.08 + } + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "电影感", + "verb": "grade_apply", + "params": { + "spec": { + "adjustColor": { + "contrast": 0.22, + "saturation": -0.1, + "shadow": 0.16 + }, + "vignetting": { + "intensity": 0.35, + "param": 0.3 + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "更锐", + "verb": "grade_apply", + "params": { + "spec": { + "sharpen": { + "intensity": 0.5 + } + } + }, + "skills": [ + "jianying-cut" + ] + }, + { + "utterance": "红色更艳", + "verb": "grade_apply", + "params": { + "spec": { + "hsl": { + "HSLRed": { + "hue": 0.56, + "saturation": 0.62, + "lightness": 0.55 + } + } + } + }, + "skills": [ + "jianying-cut", + "jianying-grade-chain" + ] + }, + { + "utterance": "把这轨静音", + "verb": "draft_patch", + "params": { + "op": "track.mute", + "params": { + "__placeholders": [ + "trackId" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "把这轨删了", + "verb": "draft_patch", + "params": { + "op": "track.delete", + "params": { + "__placeholders": [ + "trackId" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "加一条视频", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "加一条音频", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "加一段文字", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "**加字幕(带时码)**", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "这段音量调到 25%", + "verb": "draft_patch", + "params": { + "op": "video.setVolume", + "params": { + "__placeholders": [ + "segmentId", + "volume" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "这段静音", + "verb": "draft_patch", + "params": { + "op": "video.muteOn", + "params": { + "__placeholders": [ + "segmentId" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "把这段剪成两半", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "删掉这段", + "verb": "draft_patch", + "params": { + "op": "segment.delete", + "params": { + "__placeholders": [ + "segmentId" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "挪到别的位置", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "改这段的时间范围", + "verb": "draft_patch", + "params": { + "op": "segment.setTimeRange", + "params": { + "__placeholders": [ + "segmentId" + ] + } + }, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "撤销上一步", + "verb": "draft_patch", + "params": {}, + "skills": [ + "jianying-draft-edit" + ] + }, + { + "utterance": "一次做多步", + "verb": "draft_patch", + "params": { + "note": "说明行:没有对应的官方 op" + }, + "skills": [ + "jianying-draft-edit" + ] + } + ] +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/references/intent-refusal-set.json b/plugins/antianqi/jianying/references/intent-refusal-set.json new file mode 100644 index 00000000..cd3e3a1f --- /dev/null +++ b/plugins/antianqi/jianying/references/intent-refusal-set.json @@ -0,0 +1,24 @@ +{ + "schema": "jianying.intent-refusal-set.v1", + "note": "★ 这些句子【没有明确意图】⇒ 分类器必须【拒答】(说“没有明确线索”),不许硬猜成某条链/路线。这份清单守的是“绝不硬猜”那条纪律:一旦有人把“看 / 弄 / 搞 / 整”这类泛词加进关键词表,自检就会红。★ 判据是 confident=false(不是“没给候选就算拒答”)—— 给候选但明确说没把握,也算诚实的检索线索。", + "cases": [ + { "utterance": "帮我看看这个", "why": "既没有动作也没有对象" }, + { "utterance": "随便弄一下", "why": "泛词" }, + { "utterance": "你看着办", "why": "把决定推回给系统" }, + { "utterance": "搞一下", "why": "泛词" }, + { "utterance": "这个视频有点问题", "why": "只说有问题,不说是什么问题、也不说要不要改" }, + { "utterance": "照旧", "why": "依赖上下文" }, + { "utterance": "老规矩", "why": "依赖上下文" }, + { "utterance": "帮我处理一下", "why": "泛词" }, + { "utterance": "弄吧", "why": "泛词" }, + { "utterance": "这个还行吧", "why": "在问状态,不是在下指令" }, + { "utterance": "能不能用", "why": "在问,不是在做" }, + { "utterance": "搞定了没", "why": "在问进度" }, + { "utterance": "帮我改改", "why": "泛词" }, + { "utterance": "整理一下", "why": "泛词" }, + { "utterance": "弄好看点", "why": "泛词(没有具体节点/参数线索)" }, + { "utterance": "视频不太行", "why": "只说不好,不说哪一维、要不要改" }, + { "utterance": "跟上次一样", "why": "依赖上下文" }, + { "utterance": "先这样", "why": "依赖上下文" } + ] +} diff --git a/plugins/antianqi/jianying/references/intent-routes.json b/plugins/antianqi/jianying/references/intent-routes.json new file mode 100644 index 00000000..16833196 --- /dev/null +++ b/plugins/antianqi/jianying/references/intent-routes.json @@ -0,0 +1,966 @@ +{ + "schema": "jianying.intent-routes.v1", + "note": "★ 【人话 → 该走哪条链/哪条路线】的机器可读路由表。用途:给模型一个可执行的【候选建议】(classify-intent.mjs),并给测试集一个可量化的准确率。★ 定位要诚实:它是【检索线索 + 候选排序】,不是判决 —— 真正选链的仍是模型按各 Skill 的 description 判断。★ 因此关键词命中数决定\"建议强度\";没命中就说\"没有明确线索\",绝不硬猜。", + "counts": { + "skills": 7, + "routes": 6, + "entries": 38 + }, + "routes": [ + { + "id": "skill:jianying-cut:调亮一点", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "调亮一点", + "keywords": [ + "调亮一点", + "提亮", + "亮点", + "加亮", + "亮度高", + "暗一点", + "压暗", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:调暖一点", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "调暖一点", + "keywords": [ + "调暖一点", + "暖色", + "偏暖", + "冷色", + "偏冷", + "温度高", + "温度低", + "暖", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:提亮中间调、压高光", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "提亮中间调、压高光", + "keywords": [ + "提亮中间调、压高光", + "中间调", + "高光", + "暗部", + "阴影", + "高光压", + "中间调提", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:更有食欲", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "更有食欲(餐饮)", + "keywords": [ + "更有食欲", + "食欲", + "美食", + "食物", + "餐厅", + "菜品", + "诱人", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:电影感", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "电影感", + "keywords": [ + "电影感", + "电影", + "胶片", + "高级感", + "质感", + "氛围感", + "大片", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:更锐", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "更锐", + "keywords": [ + "更锐", + "锐化", + "更清楚", + "清晰度", + "变清晰", + "锐", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-cut:红色更艳", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "红色更艳", + "keywords": [ + "红色更艳", + "红色", + "鲜艳", + "艳丽", + "红一点", + "色彩艳", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "intent-map.json · grade_apply" + }, + { + "id": "skill:jianying-draft-edit:把这轨静音", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "把这轨静音", + "keywords": [ + "把这轨静音", + "静音", + "关掉声音", + "不要声音", + "消音", + "关声音", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:把这轨删了", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "把这轨删了", + "keywords": [ + "把这轨删了", + "删轨", + "删掉这轨", + "去掉轨道", + "删除轨道", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:加一条视频", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "加一条视频", + "keywords": [ + "加一条视频", + "加视频", + "导入视频", + "放个视频", + "加个片子", + "加素材", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:加一条音频", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "加一条音频", + "keywords": [ + "加一条音频", + "加音乐", + "加背景音", + "加个音频", + "配个音乐", + "加歌", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:加一段文字", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "加一段文字", + "keywords": [ + "加一段文字", + "加文字", + "加个字", + "加个标题", + "写个字", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:加字幕", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "加字幕(带时码)", + "keywords": [ + "加字幕", + "字幕", + "加字幕条", + "配字幕", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:这段音量调到 25%", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "这段音量调到 25%", + "keywords": [ + "这段音量调到 25%", + "音量", + "声音大小", + "静音一半", + "小声点", + "大声点", + "响度调", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:这段静音", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "这段静音", + "keywords": [ + "这段静音", + "这一段静音", + "这段不要声音", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:把这段剪成两半", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "把这段剪成两半", + "keywords": [ + "把这段剪成两半", + "剪成两半", + "切成两段", + "一刀两断", + "断开", + "劈成两段", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:删掉这段", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "删掉这段", + "keywords": [ + "删掉这段", + "去掉这一段", + "这段删了", + "删除这一段", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:挪到别的位置", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "挪到别的位置", + "keywords": [ + "挪到别的位置", + "挪位置", + "移动位置", + "换位置", + "移到别处", + "拖到", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:改这段的时间范围", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "改这段的时间范围", + "keywords": [ + "改这段的时间范围", + "改时长", + "改时间范围", + "拉长", + "缩短", + "改时间长度", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:撤销上一步", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "撤销上一步", + "keywords": [ + "撤销上一步", + "撤销", + "撤回", + "回退", + "还原", + "返回上一步", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-draft-edit:一次做多步", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "一次做多步", + "keywords": [ + "一次做多步", + "批量", + "一次改多个", + "多个操作", + "连续操作", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "intent-map.json · draft_patch" + }, + { + "id": "skill:jianying-cut:拉点对比度", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "拉点对比度", + "keywords": [ + "拉点对比度", + "对比度", + "拉点", + "有点平", + "太平", + "不立体", + "淡得", + "灰蒙蒙", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-cut:导个片调个色", + "kind": "skill", + "skill": "jianying-cut", + "utterance": "导个片调个色", + "keywords": [ + "导个片调个色", + "导个片", + "导片", + "调个色", + "导出来", + "导出成片", + "出个片", + "调色", + "出片", + "成片", + "色调", + "调亮", + "调暖", + "滤镜", + "10bit", + "lut", + "调色链" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-draft-edit:这轨删了", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "这轨删了", + "keywords": [ + "这轨删了", + "轨删了", + "删了这轨", + "把这轨删", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销", + "落盘" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-review:这版能交片吗", + "kind": "skill", + "skill": "jianying-review", + "utterance": "这版能交片吗", + "keywords": [ + "这版能交片吗", + "能交片吗", + "交片吗", + "能不能交", + "可以交了吗", + "能过了吗", + "审", + "验收", + "复核", + "过不过关", + "返修", + "质量", + "画面对不对", + "响度够" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-draft-edit:改完存一下", + "kind": "skill", + "skill": "jianying-draft-edit", + "utterance": "改完存一下", + "keywords": [ + "改完存一下", + "改完存", + "存一下", + "保存", + "落盘", + "草稿", + "时间线", + "轨道", + "片段", + "加个", + "删掉", + "静音", + "音量", + "字幕", + "文字", + "撤销" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-review:审一下这版", + "kind": "skill", + "skill": "jianying-review", + "utterance": "审一下这版", + "keywords": [ + "审一下这版", + "审一下", + "审这版", + "审一版", + "看看剪得对不对", + "过不过关", + "返修", + "质量怎么样", + "复核", + "这版片子", + "审", + "验收", + "质量", + "画面对不对", + "响度够" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-review:交片前检查一下", + "kind": "skill", + "skill": "jianying-review", + "utterance": "交片前检查一下", + "keywords": [ + "交片前检查一下", + "交片前", + "片前检查", + "交付前", + "审", + "验收", + "复核", + "过不过关", + "返修", + "质量", + "画面对不对", + "响度够" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-talk-cut:把这段口播的废话剪掉", + "kind": "skill", + "skill": "jianying-talk-cut", + "utterance": "把这段口播的废话剪掉", + "keywords": [ + "把这段口播的废话剪掉", + "口播", + "废话", + "口水话", + "剪干净", + "去一下口水话", + "把停顿剪了", + "有重复的剪掉", + "这段口播", + "停顿", + "重复" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-verify:这个改动该怎么证明", + "kind": "skill", + "skill": "jianying-verify", + "utterance": "这个改动该怎么证明", + "keywords": [ + "这个改动该怎么证明", + "怎么算通过", + "验收标准", + "判据", + "怎么证明", + "证明它", + "口径是什么", + "判据自检", + "判据视野", + "怎么算", + "证明", + "口径", + "自检" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-grade-chain:这个参数该填哪个节点", + "kind": "skill", + "skill": "jianying-grade-chain", + "utterance": "这个参数该填哪个节点", + "keywords": [ + "这个参数该填哪个节点", + "填哪个节点", + "调色链", + "曲线控制点", + "控制点怎么排", + "参数填哪", + "有哪些节点", + "节点", + "参数填哪个", + "曲线", + "控制点", + "色轮" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "skill:jianying-pipeline:一站式做完", + "kind": "skill", + "skill": "jianying-pipeline", + "utterance": "一站式做完", + "keywords": [ + "一站式做完", + "一站式", + "一条龙", + "全流程", + "从头到尾", + "顺便", + "改完直接出", + "走一遍", + "整条流程", + "改完直接" + ], + "source": "手工(同义词基于 §104 拆过的职责边界)" + }, + { + "id": "route:grade-and-render", + "kind": "route", + "route": "grade-and-render", + "skill": "jianying-pipeline", + "utterance": "调色并出成片('把这个草稿调暖再出片')", + "keywords": [ + "grade-and-render", + "调完色", + "调色后出", + "调完再出", + "调色并出", + "调色出片", + "调完色出" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:edit-and-verify", + "kind": "route", + "route": "edit-and-verify", + "skill": "jianying-pipeline", + "utterance": "改草稿并用语义判据验收('把这段音量调到 25%,改完确认一下')", + "keywords": [ + "edit-and-verify", + "改完确认", + "改完看有没有生效", + "改完回读", + "改完确认一下", + "改完有没有生效" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:edit-then-render", + "kind": "route", + "route": "edit-then-render", + "skill": "jianying-pipeline", + "utterance": "改完直接出片('改完直接出个片')= edit-and-verify 的前半 + grade-and-render 的出片", + "keywords": [ + "edit-then-render", + "改完直接出", + "改完顺便出", + "改完出片", + "改完直接出片", + "改完出", + "顺便出片", + "顺便出", + "顺便出成片" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:talk-cut-then-render", + "kind": "route", + "route": "talk-cut-then-render", + "skill": "jianying-pipeline", + "utterance": "剪口播再出片('把口播的废话剪了再出片')", + "keywords": [ + "talk-cut-then-render", + "口播剪完", + "剪完口播", + "口播再出", + "剪完再出片", + "口播剪了再出", + "口播", + "废话", + "剪口播" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:subtitle-then-render", + "kind": "route", + "route": "subtitle-then-render", + "skill": "jianying-pipeline", + "utterance": "加字幕并出片('给这个视频加字幕再出片')", + "keywords": [ + "subtitle-then-render", + "加字幕再出片", + "加字幕出片", + "字幕加完出片", + "带字幕出片", + "字幕出片" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:render-and-mix", + "kind": "route", + "route": "render-and-mix", + "skill": "jianying-pipeline", + "utterance": "出片并按草稿重做混音('出片时把混音按草稿重做一遍')", + "keywords": [ + "render-and-mix", + "出片并混音", + "混音出片", + "重做混音", + "按草稿混音", + "混音再出", + "混音", + "混音按草稿", + "按草稿重做" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:review-only", + "kind": "route", + "route": "review-only", + "skill": "jianying-pipeline", + "utterance": "审这一版('审一下这版' / '交片前检查一下')", + "keywords": [ + "review-only", + "审一遍这版", + "审一遍", + "自动审一遍", + "自动审片", + "审完给结论", + "这版体检" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征;★ 不抢 jianying-review 的常用说法——\"审一下这版\"那种仍归 skill,那是【怎么审】的方法问题)" + }, + { + "id": "route:review-compare", + "kind": "route", + "route": "review-compare", + "skill": "jianying-pipeline", + "utterance": "两版对照审片('把改前改后对着看一遍')", + "keywords": [ + "review-compare", + "改前改后", + "两版对照", + "两版对比", + "对着看一遍", + "跟上一版比", + "和上一版比" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征)" + }, + { + "id": "route:grade-chain-then-render", + "kind": "route", + "route": "grade-chain-then-render", + "skill": "jianying-pipeline", + "utterance": "曲线类调色并出片('按这条调色链调一版再出片')", + "keywords": [ + "grade-chain-then-render", + "调色链走一遍", + "按调色链出片", + "曲线调完出片", + "曲线调色再出片", + "曲线调完再出" + ], + "source": "pipeline-profiles.json(★ 只带该路线独有的组合特征;★ 不抢 jianying-grade-chain 的常用说法——\"这个参数该填哪个节点\"仍归那个 skill,那是【填参】的问题)" + } + ] +} \ No newline at end of file diff --git a/plugins/antianqi/jianying/references/intent-testset.json b/plugins/antianqi/jianying/references/intent-testset.json new file mode 100644 index 00000000..87898c98 --- /dev/null +++ b/plugins/antianqi/jianying/references/intent-testset.json @@ -0,0 +1,49 @@ +{ + "schema": "jianying.intent-testset.v1", + "note": "★ 带标注的意图路由测试集:每句真实用户话 ⇒ 期望落到哪个 skill / 哪条路线。\n ★ 用途:给分类器一个【可量化】的准确率(自检 selftest-intent.mjs 跑它)。\n ★ 阈值不许拍脑袋:先跑一遍看分布,再定 minTop1 / minTop3。", + "cases": [ + { "utterance": "给这个视频调亮一点", "expect": { "skill": "jianying-cut" } }, + { "utterance": "把这个片段调暖一点", "expect": { "skill": "jianying-cut" } }, + { "utterance": "帮我调出电影感", "expect": { "skill": "jianying-cut" } }, + { "utterance": "出个10bit的成片", "expect": { "skill": "jianying-cut" } }, + { "utterance": "画面看着有点平,拉点对比度", "expect": { "skill": "jianying-cut" } }, + { "utterance": "红色太淡了,鲜亮点", "expect": { "skill": "jianying-cut" } }, + { "utterance": "给我导个片,调个色", "expect": { "skill": "jianying-cut" } }, + + { "utterance": "把这个草稿里的第一段音量调到25%", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "给这轨加个文字", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "把这段剪成两半", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "撤销刚才那步", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "改完存一下", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "这轨删了", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "加一条视频进去", "expect": { "skill": "jianying-draft-edit" } }, + { "utterance": "给这个草稿加个字幕", "expect": { "skill": "jianying-draft-edit" } }, + + { "utterance": "审一下这版", "expect": { "skill": "jianying-review" } }, + { "utterance": "这版能交片吗", "expect": { "skill": "jianying-review" } }, + { "utterance": "交片前检查一下", "expect": { "skill": "jianying-review" } }, + + { "utterance": "把这段口播的废话剪了", "expect": { "skill": "jianying-talk-cut" } }, + { "utterance": "口播里的口水话去一下", "expect": { "skill": "jianying-talk-cut" } }, + + { "utterance": "这个改动怎么证明", "expect": { "skill": "jianying-verify" } }, + { "utterance": "验收标准是什么", "expect": { "skill": "jianying-verify" } }, + + { "utterance": "这个参数该填哪个节点", "expect": { "skill": "jianying-grade-chain" } }, + { "utterance": "曲线的控制点怎么排", "expect": { "skill": "jianying-grade-chain" } }, + + { "utterance": "帮我一站式做完", "expect": { "skill": "jianying-pipeline" } }, + { "utterance": "改完直接出片", "expect": { "skill": "jianying-pipeline" } }, + { "utterance": "把这条流程从头到尾走一遍", "expect": { "skill": "jianying-pipeline" } }, + + { "utterance": "调完色直接出成片", "expect": { "route": "grade-and-render" } }, + { "utterance": "改完这个草稿顺便出片", "expect": { "route": "edit-then-render" } }, + { "utterance": "改完确认一下有没有生效", "expect": { "route": "edit-and-verify" } }, + { "utterance": "把口播剪了再出成片", "expect": { "route": "talk-cut-then-render" } }, + { "utterance": "给这个视频加字幕再出片", "expect": { "route": "subtitle-then-render" } }, + { "utterance": "出片的时候把混音按草稿重做一遍", "expect": { "route": "render-and-mix" } }, + { "utterance": "把这版审一遍", "expect": { "route": "review-only" } }, + { "utterance": "把改前改后对着看一遍", "expect": { "route": "review-compare" } }, + { "utterance": "曲线调完再出片", "expect": { "route": "grade-chain-then-render" } } + ] +} diff --git a/plugins/antianqi/jianying/references/official-ops.md b/plugins/antianqi/jianying/references/official-ops.md new file mode 100644 index 00000000..38167a44 --- /dev/null +++ b/plugins/antianqi/jianying/references/official-ops.md @@ -0,0 +1,240 @@ +# 官方行为操作全集(实测 2026-10-21) + +> **★ 这份清单推翻了旧结论"官方只有 27 个行为操作"。** +> 27 那个数字来自官方 `app-server.mjs` 里的 `gk0` 常量(一个历史快照),**不是当前宿主进程的真实授权范围**。 +> 实测:官方全集 **18 个域 / 199 个 op**,我们这道桥 **全部放行(0 个被拒)**。 + +## 怎么测的 + +直接打官方管道(绕过我们插件的本地 schema),每个 op 用空参数打一次,按错误码分三类: + +| 判定 | 含义 | +|---|---| +| `accepted:true` | 放行,且空参数就能过(173 个) | +| `xxx_required` / `xxx_invalid` | **放行**,只是缺参数(26 个)⇒ 说明这个 op 存在 | +| `unsupported_behavior_operation` | 不放行(**0 个**) | + +⇒ 结论:**桥没有拦任何官方 op**。以前"只能用 27 个"是我们只试了 27 个,不是只能用 27 个。 + +## 分域清单 + +| 域 | 数量 | 干什么 | +|---|---|---| +| `draft` | 14 | 草稿级(画布/封面/名称/背景/对齐) | +| `track` | 4 | 轨道(增删静音/改名) | +| `segment` | 19 | 片段(增删改/切分/布局/可见/镜像/复制/时码) | +| `video` | 56 | 视频素材与特效(转场/淡入淡出/抠像/绿幕/变速/音量/数字人…) | +| `audio` | 24 | 音频(音量/变声/降噪/音高/人声分离/美颜…) | +| `text` | 16 | 文字与字幕(加文字/加字幕/样式/关键词/动画/追踪器) | +| `textTemplate` | 8 | 文字模板 | +| `sticker` | 7 | 贴纸 | +| `effect` | 3 | 特效(加/改参数/重置) | +| `filter` | 2 | 滤镜(加/改) | +| `adjustment` | 3 | 调色调整(加/基础色/重置) | +| `keyframe` | 3 | 关键帧(增删改) | +| `template` | 4 | 模板(可变视频/音频/文字/静音) | +| `templateCombination` | 3 | 模板组合 | +| `attachment` | 13 | 附件(AI 任务/视频算法/AI 创作) | +| `story` | 11 | 故事(AI 备选素材) | +| `undoRedo` | 6 | 撤销重做 | +| `batch` | 3 | 批处理(开始/结束/冻结) | + +## 全量 op 清单 + +### `draft`(14) + +`draft.setCanvasSize` `draft.setCanvasBackground` `draft.setCover` `draft.clearCover` `draft.setDraftName` `draft.setDraftId` `draft.setDraftUpdateTime` `draft.ensureDraftCanvasSize` `draft.updateDraftAIGCWatermark` `draft.clearDraft` `draft.setDraft` `draft.setAlignMode` `draft.setDraftKeywordsConfigs` `draft.setVideoGeneratorType` + +### `track`(4) + +`track.add` `track.delete` `track.mute` `track.updateTrackName` + +### `segment`(19) + +`segment.move` `segment.scale` `segment.translate` `segment.rotate` `segment.updateLayout` `segment.split` `segment.insert` `segment.delete` `segment.mirror` `segment.setTimeRange` `segment.innerReplace` `segment.innerReplaceStack` `segment.paste` `segment.visible` `segment.mute` `segment.setName` `segment.duplicate` `segment.setDescription` `segment.multiSegmentsSetTimeRange` + +### `video`(56) + +`video.add` `video.setMaterial` `video.setMask` `video.clearMask` `video.setOpacity` `video.setBackgroundColor` `video.setBackgroundBlur` `video.setBackgroundImage` `video.clearBackground` `video.formatVideo` `video.setTransition` `video.updateTransition` `video.clearTransition` `video.setFade` `video.denoiseOn` `video.denoiseOff` `video.addAnimation` `video.updateAnimation` `video.deleteAnimation` `video.setSpeed` `video.pitchOn` `video.pitchOff` `video.setCurveSpeed` `video.seprateAudio` `video.setMatting` `video.clearMatting` `video.reverse` `video.crop` `video.muteOn` `video.muteOff` `video.setBasicColor` `video.resetBasicColor` `video.setVolume` `video.setFigure` `video.setMakeupRoot` `video.clearFigure` `video.setFigureFaceId` `video.setMix` `video.setChromaColor` `video.setChromaIntensity` `video.setChromaShadow` `video.setChromaEdgeSmooth` `video.setChromaSpill` `video.resetChroma` `video.applyAllChroma` `video.removeSmartCrop` `video.addSmartCrop` `video.setSmartColorAdjust` `video.setStable` `video.setVideoAlgorithm` `video.addDigitalHuman` `video.updateDigitalHuman` `video.updateDigitalHumanRendered` `video.addObjectLocked` `video.updateObjectLocked` `video.removeObjectLocked` + +### ★ `draft` 域:直接读写草稿文件(2026-10-24 实测) + +| operation | 参数 | 返回 | 说明 | +|---|---|---|---| +| `readFile` | `{ path: <草稿目录>/draft_content.json, rootPath: <草稿库根> }` | `{ accepted, cipherType, content, encrypted, exists }` | ★ **能读加密草稿**:磁盘上是密文时 `content` 仍是【明文 JSON】 | +| `writeFile` | `{ path: <草稿目录>/draft_content.json, rootPath: <草稿库根>, content: <明文 JSON 字符串> }` | `{ accepted, cipherType, encrypted }` | 写入时自动加密(sealed on write) | + +> ★ 其它形态的官方报错(可用于反推): +> +> | 试法 | 官方报错 | +> |---|---| +> | `{path: 草稿目录, rootPath}` | `draft_read_failed` | +> | `{path: 草稿目录, workspace}` / `{path: 草稿目录}` | `draft_path_invalid` | +> | `{path: draft_content.json, workspace}` | `draft_path_invalid` | +> | `{draftPath: …}` / `{path:…, root:…}` | `draft_path_invalid` | +> | `operation: readDraft` / `getDraft` | `unsupported_draft_operation` | +> +> ⇒ ★ **`rootPath` 要的是【草稿库根】**(不是草稿自己的父目录)。 +> ⇒ ★ 这条路径让 `draft_read` 能读【加密草稿】—— 用户自己的真草稿在磁盘上都是加密封装, +> 而 `draft_read` 原来只走磁盘 ⇒ 读不出来。 +### `audio`(24) + +`audio.add` `audio.setVolume` `audio.setFade` `audio.denoiseOn` `audio.denoiseOff` `audio.pitchOn` `audio.pitchOff` `audio.setVoiceEffect` `audio.setVoiceEffectParams` `audio.clearVoiceEffect` `audio.setVoiceEffectToAll` `audio.setVocalSeparation` `audio.updateVocalSeparation` `audio.removeVocalSeparation` `audio.setVocalBeautify` `audio.updateVocalBeautify` `audio.resetVocalBeautify` `audio.removeVocalBeautify` `audio.setSpeed` `audio.modifiedToneOn` `audio.modifiedToneOff` `audio.setMaterial` `audio.addBeat` `audio.removeBeat` + +### `text`(16) + +`text.add` `text.setMaterials` `text.addSubtitles` `text.setFixedSize` `text.addAnimation` `text.updateAnimation` `text.deleteAnimation` `text.addVideoTracker` `text.updateVideoTracker` `text.removeVideoTracker` `text.deleteTextToAudioIds` `text.updateSubtitleTemplate` `text.resetSubtitleTemplate` `text.updateEffect` `text.setSubtitleKeywords` `text.clearSubtitleKeywords` + +### `textTemplate`(8) + +`textTemplate.add` `textTemplate.setMaterial` `textTemplate.addAnimation` `textTemplate.updateAnimation` `textTemplate.deleteAnimation` `textTemplate.addVideoTracker` `textTemplate.updateVideoTracker` `textTemplate.removeVideoTracker` + +### `sticker`(7) + +`sticker.add` `sticker.addAnimation` `sticker.updateAnimation` `sticker.deleteAnimation` `sticker.addVideoTracker` `sticker.updateVideoTracker` `sticker.removeVideoTracker` + +### `effect`(3) + +`effect.add` `effect.updateAdjustParams` `effect.resetAdjustParams` + +### `filter`(2) + +`filter.add` `filter.update` + +### `adjustment`(3) + +`adjustment.add` `adjustment.setBasicColor` `adjustment.resetBasicColor` + +### `keyframe`(3) + +`keyframe.add` `keyframe.delete` `keyframe.update` + +### `template`(4) + +`template.setMutableVideos` `template.setMutableAudios` `template.setMutableTexts` `template.setTemplateMute` + +### `templateCombination`(3) + +`templateCombination.templateCombinationAdd` `templateCombination.replace` `templateCombination.setSize` + +### `attachment`(13) + +`attachment.insertAsyncTasks` `attachment.deleteAsyncTasks` `attachment.updateAsyncTasksStatus` `attachment.insertVideoAlgorithms` `attachment.deleteVideoAlgorithms` `attachment.updateVideoAlgorithm` `attachment.insertAiWriterInfos` `attachment.deleteAiWriterInfos` `attachment.updateAiWriterInfo` `attachment.setAiCreatorContent` `attachment.updateAiCreation` `attachment.updateAiReel` `attachment.updateAiReelAgent` + +### `story`(11) + +`story.deleteAiStoryAlternative` `story.addAiStoryAlternativeMaterials` `story.cloneAiStoryAlternativeMaterials` `story.deleteAiStoryAlternativeMaterials` `story.updateAiStoryAlternativeMaterial` `story.pinAiStoryAlternativeMaterial` `story.updateMoveFeedsToAlternative` `story.updateStoryId` `story.innerReplace` `story.innerReplaceStack` `story.findInnerReplaceIds` + +### `undoRedo`(6) + +`undoRedo.undo` `undoRedo.redo` `undoRedo.canUndo` `undoRedo.canRedo` `undoRedo.getTopItemId` `undoRedo.resetUndoRedo` + +### `batch`(3) + +`batch.batchStart` `batch.batchEnd` `batch.freezeVideo` + +## 已确证参数契约的 op + +> 只列**实测过参数**的。判据是**回读草稿后字段真的变了** —— 不是"官方说 accepted"。 + +| op | 参数 | 实测依据 | +|---|---|---| +| `text.add` | `{content, startTime, duration, trackIndex, needInsertTargetTrack}`(也接受 `segInfo.material.content` / `segInfo.timeRange.start|duration`) | 落成 text 轨的段,`target_timerange` = 期望值(按 {fps{'}'} 帧对齐,误差 ≤ 1 帧) | +| `video.add` | `{path, startTime?, sourceDuration?, ...}`(顶层 `path`) | 顶层 `path` ⇒ `video_source_unreadable`(认);`videos:[{path}]` ⇒ `video_path_required`(不认) | +| `audio.add` | `{audioPath}` | `audio_source_unreadable` | +| `video.setVolume` | `{segmentId, volume}` | 草稿里 `segment.volume` 0.99 → 0.2 | +| `segment.split` | `{segmentId, splitTime}`(★ 不是 `splitTimeUs`) | 时间线 9033333 → 8033333 µs | +| `segment.delete` | `{segmentId}` | 段数 −1 | +| `text.setMaterials` | `{updates:[{segmentId, ...}]}` | 报 `text_updates_required` ⇒ 认 `updates` 数组 | +| `draft.*`(11 个) | 需先有 snapshot | 报 `draft_snapshot_required` | + +## ★ 三个坑(都是"accepted 但没生效"那一类) + +### 1. `accepted:true` ≠ 生效 +`segment.setTimeRange` 传 `startUs/durationUs` ⇒ 官方回 `accepted:true`,但回读时码变成 **0/0** +(它不认这个键名,把没识别的字段当 0 写进去了)。⇒ **判据必须是"回读草稿,字段真的变了"**。 + +### 2. `needInsertTargetTrack` + `trackIndex` 会把段塞进错的轨 +`text.add` 的 `needInsertTargetTrack=true` 时,段会插进 `trackIndex` 指向的那条轨 —— +**哪怕它是 video 轨**。⇒ 要往同一条 text 轨里放多条字幕,得: +第一条 `needInsertTargetTrack:true` 建轨 ⇒ 读回那条 text 轨的下标 ⇒ 之后 +`needInsertTargetTrack:false` + 该下标。 + +### 3. `text.addSubtitles`(官方专门的加字幕 op)参数还没探对 +已知骨架:`{type, trackIndex, segInfos:[{timeRange:{start,duration}, material, segmentId}]}`, +`type` 要**数值枚举**(`MetaTypeSubtitle=10` / `MetaTypeLyrics=11`), +`material.words[]` 是逐词时间轴(`{text, startTime, endTime}`)。 +实测:各种形态都**只建了空 text 轨、没落段**。 +⇒ **但普通 `text.add` + `startTime/duration` 已经能落出带时码的文字(= 字幕轨)** ⇒ 走那条路。 + + +## ★ 音频 AI 效果类 op(实测 2026-10-21) + +| 能力 | op | 参数 | 落成 | 能不能真算 | +|---|---|---|---|---| +| **降噪** | `video.denoiseOn`(★ **不是** `audio.denoiseOn`) | `{segmentId, isDenoise?, denoiseMode?, denoiseRate?}` | `materials.realtime_denoises[]`:`{type:"realtime_denoise", sami_name:"denoise_v2", sami_version:"1.0", is_denoise, denoise_mode, denoise_rate}` | ✅ **本地 SAMI 实时 DSP**,不依赖云端 | +| **人声美化** | `audio.setVocalBeautify` | `{segmentId, enable:true}` | `materials.vocal_beautifys[]`:`{type:"vocal_beautify", enable, ambient_sound_level:75, voice_change_mode:null, production_path:""}` | ✅ **本地实时**(`production_path` 为空 ⇒ 不是云端产出) | +| **变声** | `audio.setVoiceEffect` | `{segmentId}` | `materials.audio_effects[]` | ✅ 本地 | +| **人声分离** | `audio.setVocalSeparation` | ★ 参数未探对(6 种形态都无变化) | 结构已知:`{type:"vocal_separation", choice, removed_sounds, time_range, final_algorithm:"", production_path:""}` | ⚠️ **写引用可以,产出拿不到** —— `production_path`/`final_algorithm` 为空 ⇒ 分离是**剪映的 AI 任务**(与 ASR 同类,在 agent runtime / 云端) | + + +**★ 我们的出片链原先【不应用】这些效果(2026-10-21 查清)⇒ 现已【落地并实测】(2026-10-22)**: + +我们的出片是 **官方渲染器出画面(`player` 域)+ 我们自己用 ffmpeg 混音**: + +| 环节 | 实际做了什么 | 剪映的音频效果 | +|---|---|---| +| 画面 | 官方渲染器出 PNG 序列(`player` 域)⇒ **带**剪映的调色/特效/转场 | — | +| 音频 | 混音器自己混:`volume` / `fade_in.ms` / `fade_out.ms` / `speed`(官方 `media change-audio-speed` 保音高)/ 响度归一(ffmpeg loudnorm) | ✅ **降噪已接上**:混音器读草稿 `materials.realtime_denoises` → 按段 `extra_material_refs` 匹配 → ffmpeg `afftdn`(**近似**,见下)· 美化仍是近似且**默认关闭** | +| 官方渲染产物 | 实测**只有 video 流、没有音轨** | — | +| 官方 `media` 域 | 只有文件级工具(trim / extract-frames / trim-audio / change-audio-speed / resize / compress),**没有带效果的渲染或导出** | — | +| 官方导出器 | 被 `-100007` / `-100015` 挡死 | — | + +⇒ **降噪的完整链路已端到端实测**(草稿 op 写意图 → 混音器读草稿 → ffmpeg → 成片): + +- 成片**文件内 SNR**:无降噪 5.646 dB → 有降噪 **12.128 dB**(**+6.482 dB**) +- **负向注入组 = 0.0000 dB**(同草稿但混音器强制不应用 ⇒ 差异必须归零,实测精确归零 ⇒ 差异确实来自降噪本身) +- 判据用"文件内 SNR"(语音带 RMS − 噪声带 RMS),抗 loudnorm 归一化干扰 + +⇒ **但这是近似,不是剪映的 DSP**(剪映用 SAMI `denoise_v2`)。边界(务必一起说): +- 降噪后语音带能量比干净人声低 **3.1 ~ 5.4 dB**("带能量"口径,**不等于"听感有损"**) +- 只在**稳态粉噪**一种噪声上标定过;音乐/风声/键盘声等非稳态噪声未覆盖 +- 空洞检测(语音带 >30 ms 静音段)= 0 ⇒ 没有整段吞音;**更细粒度的音乐噪声未排除** +- 混音器不随本插件分发(它是 JYAI 侧的出片工具) + +⇒ 剩下两条路: +1. **让剪映渲染音频** —— 需要它的导出/渲染管线,我们拿不到(导出器被官方挡死)。 +2. **自己在混音链里加**(ffmpeg `afftdn` 降噪 ✅已做 / EQ + 压缩 近似"人声美化" ⚠️默认关闭; + 人声分离要模型 Demucs/Spleeter)⇒ **这才是"我们自己实现"那条路的真正价值: + 不是复刻剪映的 DSP,而是补上剪映本地链的缺口。** + +**★ 复现降噪标定时会踩的坑**: +- **ffmpeg `afftdn` 的 `nf` 参数合法范围是 `[-80, -20]`**(上游文档写的 `[-99, -5]` 在新版被拒:`Value -15.000000 for parameter 'nf' out of range [-80 - -20]`)⇒ `-20` 是能用的最强档 +- 实测在这版 ffmpeg 上 `nf` 越不负 = 降噪越多**且**语音带损失越小(nr=21 时:nf=-26 ⇒ 降噪 10.3 dB/语音保全 −5.4 dB;nf=-20 ⇒ 19.7 dB/−3.1 dB) +- 力度映射:`nr = 6 + 15*rate`;档位:`nf = -26 / -23 / -20`(mode 1/2/3)——**注意这是我们自己的强度阶梯,与剪映的 `denoise_mode` 语义无关**(剪映的档位含义在 app bundle 里不可检索) +- **`anlmdn` 在语音上完全无效**(实测降噪 0.001 dB);`arndn` 需要模型,不可用 +- **别用纯噪声当测试信号**:`afftdn` 是语音降噪器,喂纯粉噪只能测出 0.63 dB 的假象 + +**键名风格**:`isDenoise`/`is_denoise`、`denoiseMode`/`denoise_mode` 两种都认;`enable` 只认 `enable`(`isEnable` 无效)。 + +## 关键入口速查 + +| 想做的事 | 走哪个 op | +|---|---| +| **加字幕** | `text.add {content, startTime, duration}`(落成 text 轨的段) | +| 官方原生字幕(带逐词时间轴/歌词) | `text.addSubtitles`(参数待探) | +| **调色写回草稿** | `effect.add` / `effect.updateAdjustParams` / `adjustment.add` / `adjustment.setBasicColor` / `filter.add` / `keyframe.*` / `video.setBasicColor` / `video.setSmartColorAdjust` | +| 调色 material 集合(草稿里现成就有) | `materials.hsl` · `materials.hsl_curves` · `materials.color_curves` · `materials.log_color_wheels` · `materials.primary_color_wheels` · `materials.material_colors` | +| 抠像 / 绿幕 | `video.setMatting` · `video.setChroma*` · `video.applyAllChroma` | +| 变声 / 人声分离 / 美颜 | `audio.setVoiceEffect` · `audio.setVocalSeparation` · `audio.setVocalBeautify` | +| 转场 / 淡入淡出 / 变速 | `video.setTransition` · `video.setFade` · `video.setSpeed` · `video.setCurveSpeed` | +| 模板里的可变文字 | `template.setMutableTexts`(官方自己报 `template_mutable_text_action_missing` ⇒ 未实现) | +| 数字人 | `video.setFigure` · `video.setDigitalHuman` · `video.setMakeupRoot` | + +## 复现 + +```bash +node tools/probe-op-whitelist-full.mjs # 199 个 op 逐个打桥,输出放行/缺参/被拒 +node tools/probe-text-timing-final.mjs # text.add 的 startTime/duration 实测 +node tools/probe-subtitle-final.mjs # text.addSubtitles 的参数形态 +``` diff --git a/plugins/antianqi/jianying/references/pipeline-profiles.json b/plugins/antianqi/jianying/references/pipeline-profiles.json new file mode 100644 index 00000000..49afb4a5 --- /dev/null +++ b/plugins/antianqi/jianying/references/pipeline-profiles.json @@ -0,0 +1,299 @@ +{ + "schema": "jianying.pipeline-profiles.v1", + "note": "跨链路线的【机器可读】定义。★ 每一步的动词必须在 mcp/tools/*.schema.json 里,参数必须在该动词的 properties 里 ⇒ 由机械审计 7k 校验(拼错的动词/参数当场报出来)。★ $ 开头的是【变量】:$draft/$outDir/$grade 由调用者给,$sessionId/$dir 由前一步的返回带下来。", + "variables": { + "$draft": "调用者给的剪映草稿目录(真草稿,只读)", + "$outDir": "调用者给的输出目录", + "$grade": "调色 spec(形状同 grade_apply 的 spec;也可用 references/intent-map.json 里的映射)", + "$sessionId": "draft_open 返回的 sessionId(ve-session-N)", + "$dir": "draft_open 返回的副本目录(★ 草稿动词与出片动词之间的交接点)", + "$segmentId": "draft_read 里读到的段 id", + "$beforeDraft": "改前的裸草稿 JSON 路径(顶层有 tracks)", + "$afterDraft": "改后的裸草稿 JSON 路径(顶层有 tracks)", + "$srt": "用户提供的转写稿(SRT)★ 官方 ASR 拿不到,不能自己编", + "$planPath": "★ 编排器分配的产物路径(在流水线工作目录里)⇒ 路线用 alloc 声明谁来分配", + "$film": "★ 上一步产出的成片(相对 $outDir)⇒ 由步骤的 produces 声明", + "$mixed": "★ 混音后的成片(相对 $outDir)⇒ 由步骤的 produces 声明", + "$draftJson": "★ 这一版的裸草稿 JSON(由 draft_read 步骤写出)⇒ 单版审片用它看结构", + "$reviewDir": "★ 审片帧目录的父目录(相对 $outDir)⇒ 由步骤的 produces 声明", + "$noiseDir": "★ 噪声底帧目录的父目录(相对 $outDir)⇒ 同一状态再渲一次", + "$draftAfter": "★ 第二份草稿(改后那一版)⇒ 两版对照审片用", + "$sidBefore": "★ 改前那一版的会话 id(由 setVars 从 $sessionId 存下来)", + "$dirBefore": "★ 改前那一版的副本目录", + "$beforeDraftJson": "★ 改前那一版的裸草稿 JSON", + "$sidAfter": "★ 改后那一版的会话 id", + "$dirAfter": "★ 改后那一版的副本目录", + "$afterDraftJson": "★ 改后那一版的裸草稿 JSON", + "$beforeOut": "★ 改前那一版的成片输出目录", + "$afterOut": "★ 改后那一版的成片输出目录", + "$filmBefore": "★ 改前那一版的成片(相对 $outDir)", + "$filmAfter": "★ 改后那一版的成片(相对 $outDir)", + "$graded": "★ 调色后的成片路径(由 grade_apply 的响应给出 —— 只有响应知道确切文件名)" + }, + "profiles": [ + { + "id": "grade-and-render", + "when": "调色并出成片('把这个草稿调暖再出片')", + "requires": ["剪映在跑", "素材在盘上"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user", "note": "用户手上那一版" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" }, + { "name": "$grade", "kind": "object", "desc": "调色 spec(强度型)", "required": false, "from": "intent", "note": "★ 诉求能查到就自动带(intent-map.json);查不到就跳过调色" } + ], + "steps": [ + { + "verb": "draft_open", + "args": { "draft": "$draft", "copy": true }, + "yields": ["$sessionId", "$dir"], + "note": "开副本;copy 默认 true ⇒ 真草稿只读" + }, + { + "verb": "make_film", + "args": { "draft": "$dir", "outDir": "$outDir", "grade": "$grade", "denoise": "auto" }, + "note": "官方渲染器出画面 + 我们的调色/混音 + ffmpeg 编码;★ 内建验收(双流/时长/响度)失败会直接判失败" + } + ] + }, + { + "id": "edit-and-verify", + "when": "改草稿并用语义判据验收('把这段音量调到 25%,改完确认一下')", + "requires": ["剪映在跑", "素材在盘上"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user" }, + { "name": "$segmentId", "kind": "string", "desc": "要改的段 id", "required": false, "from": "derived", "note": "不给则取草稿里的第一个段" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { "verb": "draft_read", "args": { "sessionId": "$sessionId" }, "yields": ["$segmentId"] }, + { + "verb": "draft_patch", + "args": { "op": "video.setVolume", "sessionId": "$sessionId", "params": { "segmentId": "$segmentId", "volume": 0.25 } }, + "note": "可多次;每次 baseRevision 会递增" + }, + { + "verb": "draft_commit", + "args": { "sessionId": "$sessionId", "realDraft": "$draft" }, + "note": "素材路径重定位 + 真草稿 sha 断言(真草稿被改动就判失败)" + }, + { + "verb": "verify_diff", + "args": { "checks": ["timeline"], "beforeDraft": "$beforeDraft", "afterDraft": "$afterDraft", "expect": { "segmentFieldChanged": "volume" } }, + "note": "★ 主判据 = 语义(逐段字段差),完全确定;两份草稿必须是【裸草稿】" + } + ] + }, + { + "id": "edit-then-render", + "when": "改完直接出片('改完直接出个片')= edit-and-verify 的前半 + grade-and-render 的出片", + "requires": ["剪映在跑", "素材在盘上"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { "verb": "draft_read", "args": { "sessionId": "$sessionId" }, "yields": ["$segmentId"] }, + { + "verb": "draft_patch", + "args": { "op": "video.setVolume", "sessionId": "$sessionId", "params": { "segmentId": "$segmentId", "volume": 0.25 } } + }, + { "verb": "draft_commit", "args": { "sessionId": "$sessionId", "realDraft": "$draft" } }, + { + "verb": "make_film", + "args": { "draft": "$dir", "outDir": "$outDir", "grade": "$grade", "denoise": "auto" }, + "note": "★ draft 用同一个 $dir ⇒ 全程一份副本" + } + ] + }, + { + "id": "talk-cut-then-render", + "when": "剪口播再出片('把口播的废话剪了再出片')", + "requires": ["剪映在跑", "素材在盘上", "★ 用户提供 SRT(官方 ASR 拿不到,不能自己编)", "★ 保留区间表必须先给用户确认"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user" }, + { "name": "$srt", "kind": "path", "desc": "转写稿(SRT)", "required": true, "from": "user", "note": "★ 官方 ASR 拿不到 ⇒ 必须由用户提供,不许自己编" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" } + ], + "alloc": { "$planPath": "talk-cut-plan.json" }, + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { "verb": "draft_read", "args": { "sessionId": "$sessionId" } }, + { + "script": "talk-cut-plan.mjs", + "args": { "--srt": "$srt", "--out": "$planPath" }, + "confirm": true, + "artifact": "$planPath", + "confirmNote": "★ 保留/删除区间表已经生成,但【还没落刀】—— 必须先给用户确认", + "note": "产出保留/删除区间表 ⇒ confirm:true ⇒ 这一步跑完编排器会停下来等人确认(AWAIT_CONFIRM)" + }, + { + "script": "talk-cut-apply.mjs", + "args": { "--plan": "$planPath", "--draft": "$draft", "--sessionId": "$sessionId", "--apply": true }, + "note": "★ --sessionId ⇒ 复用编排器开的会话(不自己开、不自己落盘);--apply ⇒ 真剪" + }, + { "verb": "draft_commit", "args": { "sessionId": "$sessionId", "realDraft": "$draft" } }, + { "verb": "make_film", "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" } } + ] + }, + { + "id": "subtitle-then-render", + "when": "加字幕并出片('给这个视频加字幕再出片')", + "requires": ["剪映在跑", "素材在盘上", "★ 用户提供 SRT(官方 ASR 拿不到,不能自己编)"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user" }, + { "name": "$srt", "kind": "path", "desc": "转写稿(SRT)", "required": true, "from": "user", "note": "★ 官方 ASR 拿不到 ⇒ 必须由用户提供" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { "verb": "draft_read", "args": { "sessionId": "$sessionId" } }, + { + "script": "add-subtitles.mjs", + "args": { "--srt": "$srt", "--sessionId": "$sessionId", "--apply": true }, + "note": "★ --sessionId ⇒ 复用编排器开的会话(不自己开、不自己落盘);--apply ⇒ 真落字幕" + }, + { "verb": "draft_commit", "args": { "sessionId": "$sessionId", "realDraft": "$draft" } }, + { "verb": "make_film", "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" } } + ] + }, + { + "id": "render-and-mix", + "when": "出片并按草稿重做混音('出片时把混音按草稿重做一遍')", + "requires": ["剪映在跑", "★ 草稿里要有音频轨(没有音轨就只剩画面)"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user", "note": "★ 要有音频轨(没有就只剩画面)" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { + "verb": "make_film", + "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" }, + "produces": { "$film": "film.mp4", "$mixed": "mixed.mp4" }, + "note": "★ produces ⇒ 把产物路径交给后面的步骤($film = /film.mp4)" + }, + { + "verb": "audio_mix", + "args": { "draft": "$dir", "video": "$film", "out": "$mixed" }, + "note": "★ 按草稿里的音频结构重做混音,归一到 −14 LUFS" + } + ] + }, + { + "id": "review-only", + "when": "审这一版('审一下这版' / '这版片子审得过吗' / '交片前检查一下')", + "requires": ["剪映在跑", "★ 审片【只读不改】:这条路线只开副本做体检,不改草稿、不落盘"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要审的那一版草稿目录", "required": true, "from": "user" }, + { "name": "$outDir", "kind": "path", "desc": "输出目录(帧与成片)", "required": true, "from": "caller" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { "verb": "draft_read", "args": { "sessionId": "$sessionId" } }, + { + "verb": "render_frames", + "args": { "draft": "$dir", "outDir": "$reviewDir" }, + "produces": { "$reviewDir": "review" }, + "note": "★ 第一次渲染 = 这一版的帧" + }, + { + "verb": "render_frames", + "args": { "draft": "$dir", "outDir": "$noiseDir" }, + "produces": { "$noiseDir": "noise" }, + "note": "★ ★ 同一状态【再渲一次】⇒ 差异就是官方渲染器的噪声底(审片前最该先知道的数)" + }, + { + "verb": "make_film", + "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" }, + "produces": { "$film": "film.mp4" }, + "note": "★ 出片(自带时长/响度验收)⇒ 成片路径交给下一步量响度" + }, + { + "verb": "verify_diff", + "args": { + "afterDir": "$reviewDir/frames", + "noiseDir": "$noiseDir/frames", + "afterVideo": "$film", + "afterDraft": "$draftJson", + "realDraft": "$draft", + "checks": ["visibility", "picture", "audio", "timeline", "draft_integrity"] + }, + "note": "★ 单版审片:没有改前 ⇒ visibility 判不了(会明说 skipped 并给出噪声底)、picture 只给绝对值不判失败;audio/timeline/draft_integrity 是这一版的真实体检" + } + ] + }, + { + "id": "review-compare", + "when": "两版对照审片('把改前改后对着看一遍' / '这版比上一版改了什么')", + "requires": ["剪映在跑", "★ 两份草稿:改前 $draft + 改后 $draftAfter", "★ 审片只读不改(不授权动草稿)"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "改前那一版草稿目录", "required": true, "from": "user" }, + { "name": "$draftAfter", "kind": "path", "desc": "改后那一版草稿目录", "required": true, "from": "user", "note": "★ 两版对照要两份草稿" }, + { "name": "$outDir", "kind": "path", "desc": "输出目录(帧与成片)", "required": true, "from": "caller" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, + "setVars": { "$sidBefore": "$sessionId", "$dirBefore": "$dir" }, + "note": "★ 先把改前的会话/副本存下来(否则第二次开草稿会覆盖 $sessionId / $dir)" }, + { "verb": "draft_read", "args": { "sessionId": "$sidBefore" }, + "setVars": { "$beforeDraftJson": "$draftJson" } }, + { "verb": "draft_open", "args": { "draft": "$draftAfter", "copy": true }, + "setVars": { "$sidAfter": "$sessionId", "$dirAfter": "$dir" } }, + { "verb": "draft_read", "args": { "sessionId": "$sidAfter" }, + "setVars": { "$afterDraftJson": "$draftJson" } }, + { "verb": "render_frames", "args": { "draft": "$dirBefore", "outDir": "$reviewDir/before" }, + "produces": { "$reviewDir": "review" }, + "setVars": { "$beforeOut": "$outDir/before", "$afterOut": "$outDir/after" }, + "note": "★ 改前的帧;同时定下两版的成片输出目录(后面 make_film 用)" }, + { "verb": "render_frames", "args": { "draft": "$dirAfter", "outDir": "$reviewDir/after" } }, + { "verb": "render_frames", "args": { "draft": "$dirAfter", "outDir": "$reviewDir/noise" }, + "note": "★ ★ 同一状态【再渲一次】(改后那一版)⇒ 差异就是官方渲染器的噪声底" }, + { "verb": "make_film", "args": { "draft": "$dirBefore", "outDir": "$beforeOut" }, + "produces": { "$filmBefore": "before/film.mp4" }, + "note": "★ 改前的成片(音频维度要它)" }, + { "verb": "make_film", "args": { "draft": "$dirAfter", "outDir": "$afterOut" }, + "produces": { "$filmAfter": "after/film.mp4" }, + "note": "★ 改后的成片" }, + { "verb": "verify_diff", + "args": { + "beforeDir": "$reviewDir/before/frames", + "afterDir": "$reviewDir/after/frames", + "noiseDir": "$reviewDir/noise/frames", + "beforeVideo": "$filmBefore", + "afterVideo": "$filmAfter", + "beforeDraft": "$beforeDraftJson", + "afterDraft": "$afterDraftJson", + "realDraft": "$draftAfter", + "checks": ["visibility", "picture", "audio", "timeline", "draft_integrity"] + }, + "note": "★ 两版对照:像素(带噪声底校准)+ 画质 + 响度 + 逐段字段差 + 真草稿 sha" } + ] + }, + { + "id": "grade-chain-then-render", + "when": "曲线类调色并出片('按这条调色链调一版再出片' / '曲线调完出片')", + "requires": ["剪映在跑", "★ 调色 spec($grade):曲线 / HSL / 色轮这类强度型之外的", + "★ make_film 的 --grade 只能做强度型(官方预览渲染器带的那部分)⇒ 曲线类必须走这条"], + "inputs": [ + { "name": "$draft", "kind": "path", "desc": "要编排的剪映草稿目录", "required": true, "from": "user" }, + { "name": "$outDir", "kind": "path", "desc": "成片输出目录", "required": true, "from": "caller" }, + { "name": "$grade", "kind": "object", "desc": "调色 spec(曲线 / HSL / 色轮这类)", "required": true, "from": "user", "note": "★ 曲线形状没法从一句话里猜 ⇒ 必须问用户(或让用户点名某个节点)" } + ], + "steps": [ + { "verb": "draft_open", "args": { "draft": "$draft", "copy": true }, "yields": ["$sessionId", "$dir"] }, + { + "verb": "make_film", + "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" }, + "produces": { "$film": "film.mp4" }, + "note": "★ 先出画面 + 混音(这一步【不做调色】:曲线类要交给我们的逐像素链)" + }, + { + "verb": "grade_apply", + "args": { "src": "$film", "outDir": "$outDir/graded", "spec": "$grade" }, + "capture": { "$graded": "out" }, + "note": "★ 我们逐像素复刻的调色链(10 节点,曲线/HSL/色轮都能做)⇒ 自带逐节点生效探针;capture 从响应里取 out(10bit 成片的确切路径,只有响应知道)" + } + ] + } + ] +} diff --git a/plugins/antianqi/jianying/references/pitfalls.md b/plugins/antianqi/jianying/references/pitfalls.md new file mode 100644 index 00000000..a0e005c8 --- /dev/null +++ b/plugins/antianqi/jianying/references/pitfalls.md @@ -0,0 +1,466 @@ +# 踩坑清单(血买的,按"会不会再踩"排序) + +> 每条都带**症状 → 根因 → 对策**。这清单是"经验层",与 `verify-discipline.md`(判据)配套。 + +## 会静默出错的(最危险) + +### 1. 节点参数形态不对 ⇒ 整段被跳过,链却"跑通了" +- **症状**:链输出正常,但少了某个节点的效果;探针里那个节点是 0 +- **根因**:链 API 的键名/嵌套形态不匹配(各节点风格互不相同) +- **对策**:① 逐节点生效探针**内建在 `grade_apply` 动词里** ② 链 API 键名必须查 `references/chain-api.md` ③ 未知键**直接拒绝**(schema 硬校验) + +### 2. `curves` 与 `hsl_curves` 的控制点布局不同 +- **症状**:曲线形状怪、控制点不生效 +- **根因**:`curves` 是 **A,L,R**;`hsl_curves` 是 **L,A,R** +- **对策**:看第一个三元组的第二项是 x 还是 y —— 是 x ⇒ A,L,R;是 y ⇒ L,A,R + +### 3. 探针自己用错了 spec +- **症状**:多个节点报出**完全相同**的数 +- **根因**:探针用了全局 spec 而非单节点 spec +- **对策**:**"数值整齐得不正常"就是探针没在测** —— 见到整齐输出先怀疑探针 + +## 编码 / 环境 + +### 4. 本机系统默认 GBK ⇒ Python 打印非 ASCII 就炸 +- **症状**:`UnicodeEncodeError: 'gbk' codec can't encode character '³'` +- **对策**:脚本自身 `sys.stdout.reconfigure(encoding='utf-8')` + 调用方设 `PYTHONIOENCODING=utf-8` / `PYTHONUTF8=1` +- ★ **skill 要做成"任何人调用都安全"**,不能只在自己 shell 里设环境变量 + +### 5. PowerShell 内联复杂 Python/JS 会被引号吃掉 +- **症状**:语法错误、字符串被截断 +- **对策**:复杂逻辑写成 `.py` / `.mjs` 文件再执行;文档批量替换也用脚本 + +## 流程 / 判据 + +### 6. 单帧抽样当判据 +- **症状**:明明改动了却判 FAIL(或反过来假 PASS) +- **对策**:`verify_diff` 取全帧最大 + +### 6b. ★★ 拿"别的跑"留下的目录当基线(2026-10-21 实测) +- **症状**:自检显示 `maxMad=123.5 改动可见`,但那 123.5 根本不是这次改动造成的 +- **根因**:自检把**另一次跑**的"改前帧"目录当基线,拿它和**这次**的"改后帧"比 + ⇒ 比的是两次不同的改动,PASS 与被测链路无关(**假绿**) +- **对策**:基线必须**本次自产**;自检开头就渲染自己的"改前";`expect` 写死这次要验证的东西 + +### 6c. ★★★ 官方渲染器【不是确定性的】⇒ 裸像素阈值没有判别力 +- **症状**:同一份草稿渲染两次,帧就变了(14~17/108 帧),最差 MAD **48~123** + ⇒ 任何"max MAD > 0.5 ⇒ 改动可见"都是自说自话(噪声比阈值高两个数量级) +- **根因**:`jyai-b-render.mjs` 抓帧是"seek 后轮询 captureFrame,取容差内的第一帧"; + 抓哪一帧取决于调度时序 ⇒ 不确定。**收紧容差(60ms→20ms)也没用**(实测噪声依旧) +- **对策**:① 改草稿链用**语义判据**(落盘草稿的逐段字段差)② 像素判据必须传 `noiseDir` 做噪声校准 + ③ 我们自己的 GL/LUT 渲染链是确定性的,它的像素差分才可信 +- **教训**:**报"改动可见"之前,先报噪声底。** + +### 7. 两份验收并发跑写同一目录 +- **症状**:证据互相污染,数字对不上 +- **对策**:独立输出目录;文档写清目录名 + +### 7b. 用字符串收集 stdout 会毁掉二进制 +- **症状**:两个**不同**的 PNG 序列比出"0 差异" +- **根因**:`so += d`(d 是 Buffer)⇒ 走 utf8 解码 ⇒ 非法字节被替换 ⇒ 不同数据塌成同一串 +- **对策**:二进制必须用 `Buffer.concat(chunks)` 收集(插件的 `run()` 已用 `opts.binary`) +- ★ 我在 2026-10-21 的噪声探针里**又踩了一次**(自己写的探针假报"0 差异")⇒ 写完探针先自问"它会不会假绿" + +### 8. 写文档凭记忆 +- **症状**:URL / 端口 / 管道名 / 白名单 / 计数写错 +- **实测**:我把 bridge 端口编成 `4498`(代码里根本没有这个数)、把管道后缀说成"剪映主进程 pid"(实际是 `vebun.exe` 的 `ParentProcessId`) +- **对策**:写入前逐条对源码 + +### 9. 把官方的结构当我们的模板 +- **症状**:照抄了一堆 mcode 根本不认的目录(`model/`、`model-tasks.json`…) +- **对策**:**官方是素材与语义的来源,mcode 规范才是结构约束**;先读 `docs/plugin-compatibility.md` 与 `node scripts/validate.mjs(仓库根,不是插件目录)` + +### 10. "把 demo 当默认值的代码"= 没被测过的代码 +- **症状**:spec 里没有某个节点 ⇒ `KeyError` +- **根因**:`path_A` 写死调用了 `sharpen`/`vignetting` +- **对策**:按 spec 动态跑;skill 第一次用"用户自己的 spec"就把它炸出来了 + +## 协议 / 通道 + +### 11. `player/load` 的 `workspacePath` 有两个含义 +- **症状**:渲染出来"媒体丢失 / Media Not Found"红卡 +- **根因**:`player/create→load` 路径要**草稿目录的父目录**;渲染路径要**草稿目录本身** +- **对策**:别混这两个调用 + +### 12. 音频的三个量纲坑 +- **症状**:音量对不上 / 裁剪没生效 +- **根因**:① 键名带点是**字面量**(`a['duration.ms']`,不是 `a.duration?.ms`)② `timeline.*.ms` 是**毫秒**、`attributes["duration.ms"]` 是**微秒** ③ `volume` 的量纲按段类型不同(`text_to_audio` 0..1 线性 / `sound` 0..100 百分比 / 视频段 0..1) +- **对策**:一律以 timeline 为准;ffmpeg 的 `atrim`/`afade` 要**秒** + +### 13. 草稿搬过家 ⇒ `source` 是过期路径 +- **症状**:素材找不到 +- **对策**:按 basename 在 `/{video,audio}`、`/{video,audio}` 回退查找 + +### 13b. ★ `jyai-live read --path` 少了 `--root` ⇒ 静默返回空(2026-10-21 实测) +- **症状**:`draft_read` 带 path 读指定草稿,返回 `exists=undefined cipherType=undefined chars=0`,**不报错** +- **根因**:官方 `draft/readFile` 要 `rootPath` 解析 workspace;`jyai-live read` 默认取 `st.draftDir`, + 而插件读的是**别的目录**(副本)⇒ 解析不出来 ⇒ 返回空 +- **对策**:插件的 `draft_read` 现在**自动补 `--root`**(默认取 path 的上级目录) +- ★ 凡是"读别处的草稿",先确认 root 传了没有 —— **静默返回空比报错更难发现** + +## 渲染 / 数学 + +### 14. 3D LUT 表达不了邻域 / uv 依赖 +- `sharpen`(邻域)· `vignetting`(uv)· `particle`(uv)· `clear`(金字塔)⇒ **只能渲染期算** + +### 15. GPU float32 `fract` 在 ~1.2e4 量级不可复现 +- **症状**:颗粒效果逐像素对不上(69.5% 像素接近,4.7% fract 整单位跳变) +- **结论**:实用无影响,不要在这上面耗时间 + +### 16. 官方 shader 的定点编码会截断 +- `enCode` 的字节会溢出(|v|>1 时截断)⇒ 拉普拉斯差分超出 [−1,1] 会丢精度 +- 对策:链内用 float64 复刻,别在中间丢 + +### 17. 官方某些效果默认就是空转 +- `sharpen`:`white_gam`/`black_gam` 被注释掉 +- `clear`:`intensity = self.intensity * sharpIntensity`,而 `sharpIntensity` 默认 **0** ⇒ 输出逐字节等于输入 +- ⇒ **看到"官方效果没出来",先查默认参数,不是我们的 bug** + +## ★ 2026-10-22 审计新增 + +### 18. ★★★ 输入问题被误报成"链坏了"——三类都要分开报 + +同一个坑这轮出现了三次("素材缺失""源画面全黑""音轨静音"),症状都是**明明链是好的,报错却说链坏了**: + +| 症状 | 真实原因 | 该怎么说 | +|---|---|---| +| "成片文件过小/不存在" | 草稿素材不在磁盘上 ⇒ 官方渲出黑帧 ⇒ 3 秒纯黑 h264 只有 3 KB | 字节数只作**提示**;主判据 = **有视频流 + 时长对齐**;再加**首帧全黑检测** | +| "响度 −70 LUFS 偏离目标" | 音轨是**静音**(素材缺失),不是归一化坏了 | 响度 ≤ −60 LUFS 判为**静音**(输入问题)⇒ 报"音频素材缺失 N 个" | +| "★ 下列节点没有生效(探针为 0)" | 源画面**近乎全黑** ⇒ 加亮类调色被 clip 回 0 | 报"**源没有可测信息**,不是节点没生效",并附源 YAVG | + +⇒ **通用规则**:报"失败"前先分清 ①链真的坏了 ②输入本来就那样 ③参数写错了 ⇒ 三者用不同的话报。 + +### 19. ★★★ 探针只看第 0 帧 ⇒ 暗素材上误报 + +> `jyai-grade-render.py` 的逐节点生效探针原来只取 `frames[0]`。 +> 实测:源首帧 YAVG=16/255(近黑)⇒ brightness 加 0.2 / 0.6 后被 clip 回 0 ⇒ 探针=0 ⇒ 误报"节点没生效"。 +> ⇒ **改成多帧取最大**;全片都无信息时明确说"源画面近乎全黑,无法用亮度差判生效"。 + +### 20. ★★★ schema 声明了但后端不支持 ⇒ 静默忽略 + +> `draft_read` 的 `scope` 参数:schema 里有(还带 enum 校验,传非法值会报错), +> 但 **handler 根本没把它传给后端**,后端也不支持分段读 ⇒ 传合法值时**静默返回全量**。 +> 而且 `SKILL.md` 里还教用户这么用 ⇒ 用户照做会拿到错的东西。 +> +> ⇒ **要么真接上,要么从 schema 删掉**(schema 里的死参数比没有更糟,因为它看起来能用)。 +> ⇒ 已删:schema 去掉 `scope`,SKILL 的示例改掉,并加了一条回归断言(传 scope 必须被拒)。 + +### 21. ★★ 删东西之前先查"有没有东西依赖它" + +> 清理草稿库时把插件自检 e2e 依赖的两个草稿删了 ⇒ 9 条 e2e 全部 SKIP, +> 而自检计数仍然是"0 FAIL"⇒ **看着绿,其实没测**。 +> ⇒ 删之前先扫一遍 tools/ 与 scripts/ 里对它的引用。 +> ⇒ 配套:e2e 用例缺环境时必须**显式 SKIP 且不计入 PASS**。 + +### 22. ★ 跨副本比对内容必须忽略行尾符 + +> 已装副本是 git clone(有 `core.autocrlf` ⇒ 工作区 CRLF),PR worktree 是 LF +> ⇒ **sha256 逐文件比全是假差异**。⇒ 比对前统一行尾符(或直接看 `git log` / `git status`)。 +> ⇒ 真正的"落后"要用 git 看,不是哈希看。 + +### 23. ★★ 官方预览渲染器的能力边界(实测,2026-10-22) + +> 用**同源 5 稿对照**(噪声底精确为 0)量出来的: +> +> | 能力 | 官方预览渲染器 | +> |---|---| +> | 强度型调色(`materials.effects[]` 里 `type:"brightness"` + 段引用) | ✅ **它就带** | +> | 几何 / 合成(画中画 / alpha / 缩放) | ✅ | +> | 曲线 / HSL / 色轮 | ❌ | +> | 特效链(`materials.video_effects[]` / `materials.effects[]` 的 `mix_mode` 等) | ❌ | +> | `materials.placeholders[]` 的 `type:"adjust"` | ❌ | +> | forceRender 链 | ❌ | +> | 导出档渲染器 / 官方编码器 / 官方混音器 | ❌(在 app 内私有 mojo 通道后面) | +> +> ⇒ **所以"把调色写回剪映预览"只对强度型成立**;曲线类只能我们自己在出片链里做。 + +### 24. ★★★ 参数名拼错 ⇒ 烘出恒等 LUT(schema 与实现都不拦 ⇒ 出片"成功"但调色没发生) + +> 实测(2026-10-22):`make_film --grade '{"adjustColor":{"brightnessX":0.1}}'` +> +> | 层 | 表现 | +> |---|---| +> | schema | `make-film.schema.json` 里 grade 的每个节点只写了 `{type:object}` ⇒ **弱校验,放行** | +> | 实现 | 烘焙器 `normalize_chain_spec` 里 `adjustColor` 没有专门分支 ⇒ 原样透传给 `node_adjustColor` ⇒ 它只认自己那 11 个键 ⇒ `brightnessX` 被**静默忽略** | +> | 产物 | 产出的 cube 与【空 spec】的 cube **逐字节相同**(md5 `3abf7454…`)⇒ **恒等 LUT** | +> | 结果 | 出片"成功"、验收"通过"、**调色根本没发生** | +> +> ⇒ ★ **两层都要堵**: +> ① **schema 层**:`make_film` 的 `grade` 复用 `grade_apply` 的 spec 约束(跨动词复用,单一真源,零重复声明) +> ② **实现层**:烘焙器自己拒未知参数名 —— 这样**绕过 schema 直接调脚本**也堵得住 +> +> ⇒ ★ **判据**:任何一次"某节点单烘"的结果都**必须非恒等**; +> **cube 与空 spec 逐字节相同 = 恒等 = 这个节点没生效**(比"看数值"更硬,不依赖渲染确定性)。 + +### 25. ★★★ 剪映的 session id 会【回收复用】⇒ 旧快照被当成新会话的草稿(实测,2026-10-24) + +> **症状**:`draft_read {sessionId}` 读出来的草稿跟你刚 `draft_open` 的完全不是一份 +> —— 段数、时长、草稿 id 全不一样;`draft_patch` 的 split"accepted 却什么都没变"。 +> 最坑的一次:会话 155 读出来的竟是**两天前**(10-22)那个会话留下的草稿。 +> +> **根因**:剪映进程重启后 session 计数**从 1 重新数** ⇒ id 被回收。 +> 插件的状态目录里还留着上一次的 `snapshot-.json` ⇒ +> 而 `draft_read` 是**优先读快照**的 ⇒ 于是新会话"继承"了旧草稿。 +> +> **对策**:**`draft_open` 开新会话时,先把这个 id 的旧快照删掉**。 +> 另加一道:会话状态里记下草稿 id,读到的 id 对不上就**点名报错**(不默默给一份别的草稿)。 +> +> ⇒ **通用教训**:任何"按 id 缓存"的东西,都必须考虑 **id 会回收**。 + +### 26. ★★★ 官方 `draft/readFile` 在路径解析不出 workspace 时会【静默返回另一份草稿】 + +> **症状**:同一份草稿,有时读到 2 段 3 秒,有时读到 4 段 9 秒 —— 内容随时间变。 +> +> **根因**:`readFile` 带 `rootPath` 时按路径解析;解析不出来时**不报错**, +> 而是返回**剪映当前打开的那份草稿**。副本目录(不在草稿库里)最容易踩。 +> +> **对策**:① 副本目录要带对 `rootPath`(副本所在目录)② +> **关键判据一律用【会话快照】**(`draft_patch` 的响应写进去的,`draft_read` 优先读它), +> 不要拿"磁盘回落读"当基线 ③ 读到的草稿 id 与会话记录不一致 ⇒ 立刻停手。 + +### 27. ★★ 改前改后读到的【草稿形态】不同 ⇒ 比总时长比出的是误导(实测,2026-10-24) + +> **症状**:`talk-cut-apply` 打印"时间线判定 ✗ 实际 3000000(期望 7933333)", +> 而刀其实**落对了**(段确实被切成了两半)⇒ 假红。 +> +> **根因**:磁盘上的草稿是**精简形态**(段 11 个键),官方返回的是**运行时展开形态**(段 50+ 个键)。 +> 两种形态混着比总时长 ⇒ 比的是两件不同的东西。 +> +> **对策**:判据必须**形态无关**。现在用的是 +> **"这一轨的时长总量正好少了 (t1 - t0) us"** —— 只看段数与时长,形态无关。 + +### 28. ★★ 官方删除片段会把后面的段【左移补空】⇒ "区间不再被覆盖"是错的判据 + +> **症状**:明明删掉了中间段,回读却发现该区间又被别的段盖上 ⇒ 误判"没生效"。 +> +> **根因**:`segment.delete` 之后,后续段整体前移补空 ⇒ 时间线保持连续。 +> +> **对策**:判据改成 **"这一轨的时长总量少了恰好 (t1-t0)"**(位移不影响时长总量)。 +> ⇒ 顺带:**别按轨道类型(video/audio)分组**,两个 video 轨道类型一样 ⇒ 要用**轨道 id**。 + +### 29. ★★ 探针的基线是【加密草稿文件】⇒ 读不出来 ⇒ 假阴性(实测,2026-10-24) + +> **症状**:`draft_patch` 报 `effectVerified=false, changes=0`, +> 而实际段已经被切成了两半(`a1ac03@0+1500000 + C956BE38@1500000+1500000`)。 +> +> **根因**:生效探针用 `readDraftJson` 读改前的基线;**草稿在盘上是加密封装** ⇒ +> 解析失败返回 null ⇒ 探针以为"改前不存在" ⇒ **段数变化算不出来 ⇒ 说"什么都没变"**。 +> +> **对策**:读不出就**走官方管道解密**(`readDraftForDiff`:先本地解析,再官方读)。 +> ⇒ **通用教训**:判据的基线读不出来时,必须**换一条路读**,不能当成"没有基线"。 + +### 30. ★ 脚本步骤的参数键必须是【命令行 flag】⇒ 写错会静默用默认值(实测,2026-10-24) + +> **症状**:`run_pipeline` 的脚本步骤跑通了,但脚本**根本没收到参数**(用了默认值)。 +> +> **根因**:profile 里把参数写成对象键(`{"srt": "$srt"}`), +> 而脚本的 CLI 是 `--srt` ⇒ 脚本"不认识"这个键 ⇒ 静默走默认值分支。 +> +> **对策**:① profile 里脚本步骤的键**必须写成 `--xxx`** ② 机械审计 **7n** 会把 +> 每个脚本步骤的 flag 跟脚本源码里真实的 flag 对一遍,对不上就报。 +> ⇒ **通用教训**:编排器把参数传给子进程时,**键的形态本身就是契约**,必须被校验。 + +### 31. ★★ 新增路线却忘了同步 schema 的枚举 ⇒ 路线【存在却调不动】(实测,2026-10-24) + +> **症状**:往 `pipeline-profiles.json` 加了两条路线,审计全绿、文件也在, +> 可一调那条新路线就被拒:`args.profile: "subtitle-then-render" 不在允许集合 [...]`。 +> +> **根因**:`run-pipeline.schema.json` 里 `profile` 是**枚举**,而枚举**手写**、 +> 与 `pipeline-profiles.json` **没有任何对账** ⇒ 两份真源各说各话。 +> +> **对策**:机械审计 **7k** 现在做【双向对账】—— +> profile 里的每个 id 必须在 schema 枚举里,schema 枚举里的每个 id 也必须在 profile 里; +> 少一个就报 HIGH。负向注入 **㉜**(塞一条没同步枚举的路线 ⇒ 必须报)。 +> +> ⇒ **通用教训**:**枚举也是契约**。凡是"从一份真源派生出来的清单", +> 都要有一条机械检查把它们钉在一起,否则迟早漂。 + +### 32. ★★ 权重函数和它自己的注释自相矛盾 ⇒ 长词的优势被抹平(实测,2026-10-24) + +> **症状**:加了"加字幕再出片"和"出片时把混音按草稿重做一遍"两句测试, +> 两条新路线都被**通用 skill 词**抢走(命中"加字幕"、"出片")。 +> +> **根因**:权重函数写的是"长词更具体 ⇒ 权重更高",但实现是 +> `len>=3 ? 3 : len==2 ? 2 : 1` ⇒ **所有 3 字以上的词权重都一样**(全是 3 分) +> ⇒ 一句命中"加字幕再出片"(6 字) 只得 3 分, +> 反而输给命中"加字幕"(3 分) + "字幕"(2 分) 的 skill 条目(5 分)。 +> +> **对策**:权重**随长度增长**(`min(8, len)`)⇒ 长词才真的更重。 +> 加完之后 33 句测试句 top-1 **100%**(原来 31 句时也是 100%,新加的两句一开始是 2/2 错的)。 +> +> ⇒ **通用教训**:**注释说了什么 ≠ 代码做了什么**。 +> 权重/阈值这类"语义规则",必须有**跟注释一致的实现**, +> 否则注释会变成一句没人验证的漂亮话。 + +### 33. ★★★ "PASS" 不等于"全查过了" ⇒ 没判的维度必须点名(实测,2026-10-24) + +> **症状**:单版审片跑完,`verdict: PASS`、`problems: []` ⇒ 看着像"全部通过", +> 可实际上 `visibility` 维度根本没判(没有改前可比)。 +> +> **根因**:`verify_diff` 把"跳过的维度"(`skipped`)只写进 `checks` 里, +> 不进结论 ⇒ 读结论的人只看到 PASS ⇒ **"没发现问题"被当成了"没有问题"**。 +> +> **对策**:结果里新增 **`undetermined`**(列出没判的维度 + 原因)与 **`verdictNote`** +> ("有 1 个维度【未判定】(visibility) ⇒ 这不是'完整通过';未测的维度要写成'待确认',不能写成'正常'")。 +> ★ 这正是官方 `editing-review` 的纪律:**没有足够证据的必查项要保留为"待确认"**。 +> +> ⇒ 顺带:**单版审片**(只给改后)现在也是一等公民: +> visibility 判不了 ⇒ 明说 skipped 并**给出噪声底**;picture 只给绝对值**不判失败**; +> audio / timeline / draft_integrity 照判。 + +### 34. ★★ 编排器"跑完了"但报告里看不到结论(实测,2026-10-24) + +> **症状**:审片路线跑完 `verdict: PASS`,可**报告里一个审片数字都没有**。 +> +> **根因**:编排器的步骤摘要只保留一份**白名单字段**,而 `verify_diff` 的结论在 +> `checks` 里(不在白名单)⇒ 被丢掉了 ⇒ **路线跑成了,却没产出它该产出的东西**。 +> +> **对策**:`verify_diff` 走**专用的紧凑摘要**(保留各维度关键数字,丢掉逐帧明细, +> 否则 `frameDetail` 会把报告撑爆)⇒ 并把 `undetermined` / `verdictNote` 一起带出来。 +> +> ⇒ **通用教训**:**一条链的产出是什么,要和它的摘要机制对齐**。 +> "跑成功"和"产出了结论"是两件事。 + +### 35. ★★★ 判据写成了"响度值本身"而不是"偏离目标的量"(实测,2026-10-24) + +> **症状**:报告里自相矛盾 —— +> `"integratedLufs": -14` 与 `"响度未归一到 −14 LUFS"` 同时出现。 +> +> **根因**:判据写成 `Math.abs(c.integratedLufs) > 0.5` +> ⇒ 它比较的是**响度值本身**(−14 的绝对值 = 14 > 0.5)⇒ +> **任何正常归一到 −14 LUFS 的片子都会被说成"未归一"**。 +> +> **对策**:判偏离量 `Math.abs(lufs − (−14)) > 0.5`。 +> +> ⇒ ★ **通用教训**:*判据要比较"目标 vs 实测",不是"实测 vs 0"*。 +> 差一个括号,判据就从"有判别力"变成"永远报警"。 +> ⇒ 而且这类 bug **不会让自检变红**(它只多了一句 note)⇒ +> **只能靠人读报告发现** ⇒ 所以审片结论必须**逐条人眼过一遍**。 + +### 36. ★★ 一份路线里开两份草稿 ⇒ `$dir` / `$sessionId` 会被覆盖(实测,2026-10-24) + +> **症状**:两版对照审片的第 2 步拿到了**改后**的副本目录,改前的副本"丢了"。 +> +> **根因**:`draft_open` 总是把产出写进同一组变量(`$sessionId` / `$dir`)⇒ +> 第二次调用把它们覆盖了。 +> +> **对策**:编排器新增 **`setVars`**(`{"$dirBefore": "$dir"}`) +> —— 在**这一步之后**求值(抓的就是这一步的产出),值可以是变量引用或含 `$var` 的模板 +> ⇒ 第一个副本先存成 `$dirBefore`,第二个再覆盖不迟。 +> ⇒ 顺带:草稿 JSON 文件名按**步骤号**命名(`draft-.json`)⇒ 读多份草稿不互相覆盖。 +> ⇒ 审计守卫:`setVars` / `produces` 的目标变量与引用变量都必须在 `variables` 里声明; +> `produces` 的路径不许含 `..` 或绝对路径。 + +### 37. ★★ 产物路径只有"响应"知道 ⇒ 需要 `capture`(实测,2026-10-24) + +> **症状**:`grade_apply` 的产物(10bit 成片)的**确切文件名**只有它的响应里知道 +> (`out` / `outLut`)⇒ 编排器没法把它喂给下一步。 +> +> **对策**:新增 **`capture`**(`{"$graded": "out"}`)—— +> 在步骤**之后**从响应对象里取值当变量;**响应里没有这个字段 ⇒ 明确报错** +> (不静默塞 undefined)。 +> +> ⇒ 至此编排器有三种"把东西往后传"的方式,各管一段: +> +> | 机制 | 时机 | 干什么 | +> |---|---|---| +> | `produces` | 步骤**之前** | 声明这一步的产物**路径**(相对 `$outDir`) | +> | `capture` | 步骤**之后** | 从**响应**里取一个值(只有响应知道的东西) | +> | `setVars` | 步骤**之后** | 给已有变量起别名 / 派生模板 | + +### 38. ★★ JSON 里写 `+` 字符串拼接 ⇒ 解析失败(同一个坑栽了两次) + +> **症状**:改了 `pipeline-profiles.json` 后,审计直接**崩栈** +> (`SyntaxError: Expected ',' or '}' after property value`),什么发现都出不来。 +> +> **根因**:JSON **不支持**跨行字符串拼接,我在 `note` 字段里写了 +> `"note": "前半" + "后半"` ⇒ 它是 JS 的写法,不是 JSON。 +> +> **对策**:① `note` 写成单个字符串 ② **审计遇到坏 JSON 要报成一条发现而不是崩栈** —— +> 现在审计用 `readJson()` 包住解析,坏 JSON ⇒ 一条 HIGH 发现 + 继续跑别的检查 +> (**审计工具不能因为被审对象写错就失能**)。 +> +> ⇒ ★ 通用教训:*写机器可读配置时,"看起来像代码"的写法要格外小心*; +> ⇒ ★ **工具的失败方式也要设计**:崩栈 = 失能,报一条发现 = 还能继续查别的。 + +### 39. ★★★ "绝不硬猜"这条纪律,从来没被测过(实测,2026-10-24) + +> **症状**:`classify-intent.mjs` 的注释写着"绝不硬猜:没命中就明说没有明确线索", +> 代码里也真的有 `confident: false` 的分支 —— 但**没有任何测试量过它**。 +> ⇒ 谁往关键词表里加一句"看看""弄一下",分类器就开始硬猜,而**没有任何东西会红**。 +> +> **对策**:新增 **`references/intent-refusal-set.json`**(18 句没有明确意图的话: +> "帮我看看这个"/"你看着办"/"老规矩"/"整理一下"…)⇒ 自检断言 +> **一句都不许被判成某条链**(硬猜数必须为 0)。 +> +> ⇒ ★ 顺带把分类器从"给建议"升级成"**给判决**"(`--decide`),两种拒答: +> +> | 结果 | 条件 | 含义 | +> |---|---|---| +> | 判决 | 最高分 ≥ 2 **且** 与次优**不同目标**的分差 ≥ 1 | 照它走 | +> | 拒答·没线索 | 最高分 < 2 | 根本没线索 ⇒ 问用户 | +> | 拒答·分不清 | 两个**不同目标**分差 < 1 | 有线索但分不清 ⇒ 两个都摆给用户 | +> +> ⇒ ★ **分差只跟"不同目标"比**:同一个 skill 的两条条目并列("电影感"与"导个片调个色" +> 都指向 jianying-cut)⇒ 那是同一个答案,不是竞争者 ⇒ 不该因此拒答。 +> +> ⇒ ★ 审计新增 **7o**:拒答集必须存在且 ≥ 10 句;不许与主测试集重叠(期望自相矛盾); +> 拒答句里不许藏路线 id(那是明确的意图信号)。负向注入 **㉝**。 + +### 40. ★★★ 缺参要【一次性点名】,而且要标出"该问用户"(实测,2026-10-24) + +> **症状**:路线跑到第 3 步才报"缺少变量:$srt" ⇒ 用户已经等了半天, +> 而且不知道**还缺什么**(其实还缺 `$outDir`),也不知道**哪些只能问用户要**。 +> +> **对策**: +> ① 每条路线在 `pipeline-profiles.json` 里声明结构化 `inputs` +> (`name` / `kind` / `desc` / `required` / `from`∈{user, caller, derived, intent}) +> ② `run_pipeline` **开跑前预检**:缺什么**一次全点名**,并标出 `from=user` 的那些 +> ("★ 必须由用户提供") +> ③ 判决(`classify-intent.mjs --decide`)也带出 `routeInputs` / `mustAsk` / `params` +> ⇒ **自然语言 → 判决 → 该问用户什么 → 补齐 → 执行**,中间没有"猜"。 +> +> 实测(`talk-cut-then-render` 故意不给 srt/outDir): +> +> ```text +> 路线 "talk-cut-then-render" 还缺 2 个必需输入: +> · $srt(转写稿(SRT))—— ★ 必须由用户提供;★ 官方 ASR 拿不到 ⇒ 必须由用户提供,不许自己编 +> · $outDir(成片输出目录)—— 由调用方给 +> ⇒ 补齐后再跑;缺什么就问什么,不要拿默认值糊过去。 +> ``` +> +> ⇒ 审计 **7p**:每条路线必须有 `inputs`;每个输入名必须是已声明的变量; +> `from` 只能是那四个值;**必需输入必须在步骤里真的被用到**(声明了却不用 ⇒ 白声明)。 +> +> ⇒ ★ **一条铁律**:*参数缺了要【一次性说清】,别让人一轮一轮地试*。 + +### 41. ★★★ 依赖的外部子系统卡死时,报错要说清【是谁坏了】(实测,2026-10-24) + +> **症状**:插件所有 e2e 突然全红,报 +> `could not resolve the agent host parent pid (is JianyingPro running?)` +> —— 可**剪映明明在跑**(9 个进程都在)。 +> +> **真相**:坏的不是剪映,是 **WMI 的提供程序主机**(`WmiPrvSE.exe`): +> 它从 9-29 起累计烧掉 **704178 秒 CPU**(≈8 天)⇒ 已经跑飞 ⇒ +> `Get-CimInstance` / `tasklist` 全部 **ETIMEDOUT**(20 秒超时)⇒ +> 桥拿不到 VE runtime 的管道 pid ⇒ 整条链死在那儿。 +> +> ⇒ ★ 原来的报错是**误导性**的:它把"我依赖的查询卡死了"说成了"剪映没跑"。 +> +> **修法(插件侧)**: +> ① 超时从 20 秒压到 **6 秒**(别让人干等) +> ② 失败时**分开说两种情况**: +> - 剪映进程不在 ⇒ "先启动剪映" +> - 剪映在跑但找不到 VE runtime ⇒ "剪映在跑,可它的本地服务没起来/掉了 +> ⇒ 在剪映里【打开一个草稿】会把它拉起来;或重启剪映。**这不是插件的问题**" +> ③ 加一条**兜底**:枚举命名管道(管道名里带 pid)—— +> ★ 但实测发现 **VE 的管道列不出来**(服务活着时目录里也没有)⇒ 只能当兜底,不能当主方法。 +> +> **修法(机器侧)**:杀掉跑飞的 `WmiPrvSE.exe`(服务会自动重拉)⇒ +> CIM 查询从 20 秒超时变成 **550 毫秒**返回 ⇒ 插件立刻恢复。 +> +> ⇒ ★ **通用教训**: +> ① *报错要指向【真正坏掉的那一层】* —— "依赖超时" ≠ "依赖没装/没跑"; +> ② *外部子系统的健康要能被观测* —— 否则一次外部抖动会被误判成自己的 bug; +> ③ ★ **"整链突然全红 + 错误信息指向一个明显不对的东西"** ⇒ 先查那件"明显不对的东西" +> (这里:错误说剪映没跑,而剪映在跑 ⇒ 说明是**查询**坏了,不是**对象**没了)。 diff --git a/plugins/antianqi/jianying/references/verify-discipline.md b/plugins/antianqi/jianying/references/verify-discipline.md new file mode 100644 index 00000000..a4661687 --- /dev/null +++ b/plugins/antianqi/jianying/references/verify-discipline.md @@ -0,0 +1,161 @@ +# 判据纪律(任何"通过"都必须过这一关) + +> 这份是**护栏的知识层**。机械部分在 `scripts/` 与 MCP 动词的内建检查里。 +> ★ **护城河不在工具,在判据** —— 工具(读草稿/渲染/调色)谁都能调;"怎么知道它真的生效了"是我们独有的。 + +⇒ **"噪声"大部分是判据的放大,不是画质的崩坏** —— 人眼在 GUI 的连续播放里察觉不到,但逐像素静态比对会把它放大。 + +**★ 而且非确定性是【逐帧】的**(2026-10-21 §82 实测): + +| 测法 | 结果 | +|---|---| +| 同一草稿渲两遍 | **69/108 帧不同**;frame 001~004 是 MAD≈123 / maxAbs 255 / 95% 像素全变(整帧不同),frame 000/005~009 只差 0.47~1.69 | +| 同一时刻**连着抓两次** | **4/6 时刻像素就不同** ⇒ 非确定发生在"每一次抓帧"这一层,不是"隔一段时间重渲染" | +| 抓到的 ts 与目标 t 的偏差 | **恒为 0** ⇒ **不是抓错帧、不是时间对齐问题** | +| 另一个简单草稿 | **0/6 不确定** ⇒ **不是整个渲染器都不确定**(只是特定时刻/特定素材帧) | + +⇒ **判据必须【逐帧】校准**:`bar_i = max(threshold, noise_i × noiseK)`, +**不能用全局最大噪声**(那会把稳定帧的真实改动一起压死 ⇒ 假 FAIL)。 +`verify_diff` 的 visibility 维度现在输出 `unstableFrames` / `visibleMaxMad` / `visibleFrame` / `frameDetail[]`。 + +**★ 顺带澄清两类"对齐"必须分开说**: + +| 比什么 | 参照 | 确定性 | +|---|---|---| +| 我们的复刻 **vs 官方 shader** | 本机跑官方 `.frag` shader 的**离线 GL 渲染** | ✅ 确定 ⇒ **≤1 LSB 结论不受影响** | +| 官方渲染帧 **vs 官方渲染帧** | 预览渲染器抓的帧 | ⚠️ 逐帧部分非确定 ⇒ 必须逐帧校准 | + +**⇒ 纪律:像素判据必须逐帧校准;没有 `noiseDir` 就标"未校准",不许装作可信。** + +## 三层判定 + +``` +协议层说支持 ≠ 契约层被接受 ≠ 渲染层生效 +``` + +| 层 | 判什么 | 怎么判 | +|---|---|---| +| **契约层** | 字段认不认 | 材料存活 / 回写明文 / 字段保留 | +| **渲染层** | 有没有效果 | 无头取帧 + **A/B 逐像素差分** + **阳性对照** | + +## 十条硬规矩 + +### 1. 判据的"视野"必须覆盖被测对象 +**只抽一帧 = 用抽样代替了被测对象** ⇒ 假 FAIL 和假 PASS 都会出。 + +实测(2026-10-21):单帧抽样选的第 5 帧 MAD=0.47(阈值 0.5)判 FAIL, +而同一批帧里第 74 帧 MAD=123.5 / maxAbs=255 / 100% 像素变化。 +⇒ **对策:`verify_diff` 一律取全帧最大(max-over-frames)。** + +### 2. 阴性对照必须存在 +每个"能跑"都要配一个"不能跑"的对照。 +实测:官方抽取帧接口**全是预览路径**(ΔYAVG 0.00)⇒ "抽帧里没有调色"是**官方设计**,不是我们的 bug。 +**没有阴性对照的 PASS 一律不算 PASS。** + +### 3. 输出必须能自证是"这次跑出来的" +**同一输出目录不许并发跑两份验收**(会互相覆盖帧 ⇒ 证据被污染)。 +⇒ 每次跑用**独立输出目录**,并在文档里写清目录名。 + +### 4. 探针要能区分"没生效"和"没在测" +**所有节点数值一模一样 ⇒ 探针没在测单节点。** +(实测踩过:探针用全局 SPEC 而非单节点 spec ⇒ 6 个节点报出完全相同的数。) + +### 5. 参数形态不对 ⇒ 静默失效 +链的每个节点要么真生效、要么被整段跳过,**不会报错**。 +实测踩过:`hsl` 键名写错 ⇒ 节点被跳过,**链看着跑通、实际少一个节点**。 +⇒ 对策:**逐节点生效探针内建在动词里**(`grade_apply` 每个节点单开必须产生非零改变,否则判失败)。 + +### 6. 注入坏了必须失败(negative-injection) +每个判据都要能被"故意弄坏"而失败。已做过的注入: + +| 注入 | 期望 | 实测 | +|---|---|---| +| 坏 LUT 维度顺序(.cube 转置) | LUT 误差爆 | 0.000219 → 0.268430(放大 **1223×**) | +| 逐点算子整体压掉 | 逐点误差爆 | 0.000119 → 0.164800 | +| 金字塔接线差一级 | 自洽性爆 | 9.7e-19 → 5.7e-2 | +| 改画质到 0 | 探针爆 | 逐节点探针报"没有生效" | +| 同一目录对同一目录 | 判 FAIL | maxMad=0 ⇒ FAIL ✓ | +| **亮度 −0.01 当噪声** | 校准后判"不可见" | maxMad=5、噪声底=5、判据线=7.5 ⇒ FAIL ✓ | +| **亮度 −0.06 当真改动** | 校准后判"可见" | maxMad=19、倍数 3.8 ⇒ PASS ✓ | +| **白底 +0.2 推到 255** | 必须报"新增削波" | clipΔ=33.33% ⇒ problems 命中 ✓ | +| **expect 说 volume 不该变** | 必须 FAIL | 逐段字段差检出 volume 变化 ⇒ FAIL ✓ | + +### 7. ★★ 像素判据必须先量【噪声底】(2026-10-21 实测,最重要的一条) + +**官方渲染器不是确定性的。** 同一份草稿**渲染两次**,帧就已经不一样: + +| 样本 | 不同的帧 | 最差 MAD | 备注 | +|---|---|---|---| +| 容差 60ms(原始) | 14/108 | **48.19** @frame39 | 整帧不同(maxAbs=255) | +| 容差 20ms(收紧后) | 17/108 | **123.55** @frame73/74 | 整帧不同 | + +⇒ **两次渲染的噪声底(≈48~123)比任何"改动可见"阈值(0.5)高两个数量级。** +⇒ **任何裸 MAD 都不能证明"改动可见"** ——包括历史上写进文档的 123.51 @frame74。 + +**根因**:`jyai-b-render.mjs` 抓帧是"seek 之后轮询 captureFrame,拿时间戳在容差内的第一帧"。 +容差 60ms > 半帧周期(83ms)⇒ 会接受**邻近帧**;抓哪一帧取决于调度时序 ⇒ 不确定。 +(收紧到 20ms 后 108/108 全部精确命中、兜底 0、丢失 0,**但噪声依旧** ⇒ 噪声不是容差造成的。) + +**对策(三选一,按可靠性排序)**: + +1. **用语义判据**(推荐):改草稿链的"生效"证据 = **落盘草稿的逐段字段差**(如 `volume` 0.99→0.2), + 完全不依赖像素,确定性、可复跑。`verify_diff` 的 `checks:['timeline']` + `expect.segmentFieldChanged` 就是这个。 +2. **噪声校准**:给 `verify_diff` 传 `noiseDir`(同一状态**再渲染一次**的帧), + 判据线自动抬到 `max(threshold, noiseMad × 1.5)`;不传就在输出里标 `calibrated:false` + note。 +3. **用我们自己的渲染器**:`grade_apply` 走的是我们自己的 GL/LUT 链(确定性)⇒ 它的像素差分是可信的。 + +> **纪律:报"改动可见"之前,先报噪声底。** 没有噪声底就不要报"可见"。 + +### 8. ★★ 阳性对照必须与被测项【同路径、同对象】,只换一个已知会生效的东西 + +> 2026-10-22 审计实测:判"官方预览渲染器不执行 effect"时,带 effect 与不带 effect 的两稿 +> 逐像素完全相同(看起来是结论)。但把对照换成 `adjust`(已知官方预览**会**应用)后, +> **adjust 也完全相同** ⇒ 装置坏了,结论不成立。 +> +> ⇒ **"没变"有三种可能:①真的不支持 ②没生效/没挂上 ③装置坏了**。 +> 区分它们只有一个办法:**同一条路径、同一个对象、只换一个已知会生效的东西**, +> 它也"没变" ⇒ 判定装置有问题,换装置再测。 + +### 9. ★★ 判据的"期望值"必须先在【已知正确的实现】上成立 + +> 2026-10-22 实测:我拿"改了 R 通道 ⇒ G/B 通道必须逐字节不变"当硬判据, +> 而**官方 curves 语义里 Y(亮度)曲线会等量改 R/G/B 三个通道**(为保色相,这是有意的)。 +> ⇒ 那个判据与被测对象语义矛盾 ⇒ 拿它判自己的实现 = **自己判自己失败**。 +> +> ⇒ **写判据前先拿"官方明确对齐的那份实现"跑一遍**(比如 curves 的官方布局 spec), +> 看它是否满足你的期望 ⇒ 满足才说明你的期望是对的。 + +### 10. ★ 噪声底必须【逐帧】,不能用平均 MAD + +> 官方预览渲染**非确定**:同一草稿渲两遍,整片平均 MAD 可能是 0.00000,也可能是 0.01602 +> (差 1 帧、**maxAbs 255**)⇒ 用平均 MAD 判"是否显著"会**随机翻车**。 +> +> ⇒ 用逐帧判据线:`bar_i = max(threshold, noise_i × noiseK)` +> (这一条已经落在 `verify_diff` 的实现里,§82 修正;这里补的是"为什么不能用平均") + +## 数字底线(我们复刻链的验收口径) + +| 对象 | 口径 | 实测 | +|---|---|---| +| 调色链(逐点) | maxAbs ≤ **1 LSB** | 1 LSB(hsl_curves / curves / hsl / logWheel / colorCorrection 全部) | +| 调色链(65³ LUT) | maxAbs ≤ **2 LSB** | 2 LSB(对齐 §54 curves 的 65³ precedent 0.0086) | +| 曲线恒等 | **字节完全一致** | 256/256 texel | +| 分级 | 1 = 像素完全相同 | 1 LSB ⇒ 归一到 2 LSB | +| ~~改动可见性~~ | ~~max MAD > 阈值~~ | ⚠️ **作废**(123.51 @frame74 与噪声底同量级 ⇒ 不可信)。改用语义判据或噪声校准 | +| 真草稿 | sha 逐字节相同 | 64 files identical | + +## 调色的"生效"判据(三条同时成立才算生效) + +1. **逐节点探针**:每个节点单开 ⇒ MAD > 0 +2. **全链改变量**:`grade_change_mad > 0` +3. **空间分布合理**:改"亮"应该 mean 上升、**clip 比例不超阈值**(过曝是另一个问题,不是"调亮") + +## 判据自检清单(每次写新判据时问自己) + +- [ ] 判据的视野覆盖被测对象了吗?(不是抽一帧) +- [ ] 有阴性对照吗? +- [ ] 故意弄坏它会失败吗? +- [ ] 探针能区分"没生效"和"没在测"吗? +- [ ] 输出能自证是这次跑出来的吗? +- [ ] ★ **像素判据的噪声底量了吗?**(同一状态重渲染 ⇒ 差异有多大?没有噪声底就别报"可见") +- [ ] ★ **基线是【这次跑】产生的吗?**(借用别的跑的残留目录 ⇒ 测的是两次不同的改动) diff --git a/plugins/antianqi/jianying/scripts/add-subtitles.mjs b/plugins/antianqi/jianying/scripts/add-subtitles.mjs new file mode 100644 index 00000000..47a2825f --- /dev/null +++ b/plugins/antianqi/jianying/scripts/add-subtitles.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * 字幕轨落稿器:SRT / plan → 草稿里的"带时码的文字"(剪映里的字幕轨) + * + * ★ 实测契约(2026-10-21,桥的归一化函数 oC_ 明确支持): + * text.add { content, startTime, duration, trackIndex, needInsertTargetTrack } + * · content :字幕文本(也接受 segInfo.material.content) + * · startTime :起始(微秒),缺省 0 + * · duration :时长(微秒),缺省 3_000_000 + * · trackIndex:目标轨索引(缺省 0) + * · needInsertTargetTrack:true(缺省)=官方新建一条 text 轨;false=塞进 trackIndex 指定的既有轨 + * ★ 坑:needInsertTargetTrack=false 且 trackIndex 指向【非 text 轨】⇒ 段会被塞进那条 video 轨 + * ⇒ 所以第一条用 true(让官方建轨),读回那条 text 轨的【下标】,后面的都用 false + 那个下标 + * 实测:text.add {content:'第一句字幕', startTime:1000000, duration:2500000} + * ⇒ 落成 text 轨 1 段,target_timerange = {start:1000000, duration:2500000} ✓ + * + * 用法: + * node scripts/add-subtitles.mjs --srt --draft "<草稿名>" [--apply] [--style-size N] + * node scripts/add-subtitles.mjs --plan --draft "<草稿名>" [--apply] + * --apply 真的改草稿(默认只演练) + * ★ --sessionId :复用【别人】开好的会话(编排器 run_pipeline 就是这么接的) + * —— 不自己 draft_open、不自己 draft_commit(落盘归编排器的 draft_commit 步骤管)。 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const has = (k) => argv.includes(k); + +const SRT = arg('--srt', null); +const PLAN = arg('--plan', null); +const DRAFT = arg('--draft', null); +const SID = arg('--sessionId', null); +const APPLY = has('--apply'); +if ((!SRT && !PLAN) || (!DRAFT && !SID)) { + console.error('用法: node add-subtitles.mjs (--srt | --plan ) --draft "<草稿名>" [--apply] [--sessionId ]'); + process.exit(2); +} + +function call(verb, args) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, 'run-verb.mjs'), verb, JSON.stringify(args)], + { stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => { + let j = null; try { j = JSON.parse(so); } catch { /* not JSON */ } + resolve({ code, json: j, text: so, stderr: se, failed: code !== 0 || j?.isError === true }); + }); + }); +} + +/* ---------- SRT 解析(与 talk-cut-plan 同一套口径) ---------- */ +const tsToMs = (s) => { + const m = /(\d+):(\d+):(\d+)[,.](\d+)/.exec(String(s).trim()); + if (!m) return null; + return (+m[1]) * 3600000 + (+m[2]) * 60000 + (+m[3]) * 1000 + Math.round(+m[4] / 10); +}; +function parseSrt(text) { + const cues = []; + const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/); + for (const b of blocks) { + const lines = b.split('\n').map((l) => l.trim()).filter(Boolean); + if (!lines.length) continue; + const ti = lines.findIndex((l) => l.includes('-->')); + if (ti < 0) continue; + const [a, z] = lines[ti].split('-->'); + const startMs = tsToMs(a), endMs = tsToMs(z); + if (startMs === null || endMs === null) continue; + cues.push({ index: cues.length + 1, startMs, endMs, text: lines.slice(ti + 1).join(' ').trim() }); + } + return cues; +} + +/* ---------- 取字幕源 ---------- */ +let cues = []; +if (PLAN) { + const p = JSON.parse(fs.readFileSync(PLAN, 'utf8')); + cues = (p.cues || []).map((c) => ({ index: c.index, startMs: c.startMs, endMs: c.endMs, text: c.text })); +} else { + cues = parseSrt(fs.readFileSync(SRT, 'utf8')); +} +cues = cues.filter((c) => c.text && c.endMs > c.startMs); + +console.log('=== 字幕落稿 ==='); +console.log(` 字幕条数: ${cues.length}`); +console.log(` 时间跨度: ${(cues[0]?.startMs / 1000 || 0).toFixed(2)}s ~ ${((cues.at(-1)?.endMs || 0) / 1000).toFixed(2)}s`); +console.log(` 模式: ${APPLY ? '★ 真改草稿' : '演练(只打印)'}`); +console.log(` 草稿: ${DRAFT || (SID ? '(复用会话,不指定)' : '')}\n`); +if (!cues.length) { console.log('没有可用的字幕条'); process.exit(0); } + +if (!APPLY) { + for (const c of cues) { + console.log(` #${c.index} ${(c.startMs / 1000).toFixed(2)}s +${((c.endMs - c.startMs) / 1000).toFixed(2)}s ${c.text}`); + } + console.log('\n(演练模式,未改草稿。加 --apply 真改)'); + process.exit(0); +} + +/* ---------- 真改 ---------- */ +/* ★ 会话来源:① 复用别人开好的(--sessionId)② 自己开一个 + —— 编排器走 ①:不然会跟编排器的 session 抢,两个进程各改各的。 */ +let sid = SID; +let copyDir = null; +if (sid) { + console.log(' 复用会话 %s(不自己开、不自己落盘)\n', sid); +} else { + const open = await call('draft_open', { draft: DRAFT, copy: true }); + if (open.failed) { console.error('打开草稿失败:\n' + (open.text + open.stderr).slice(0, 400)); process.exit(3); } + sid = open.json.sessionId; + copyDir = open.json.dir; + console.log(` 副本: ${copyDir}\n`); +} + +let textTrackIndex = -1; // 已存在的 text 轨下标(第一条落进后确定) +let ok = 0, fail = 0; + +for (const c of cues) { + // ★ 实测:needInsertTargetTrack=true 时,官方会把段插进 trackIndex 指向的那条轨 + // (哪怕它是 video 轨!)⇒ 所以 trackIndex 必须指向一条【text 轨】。 + // 没有 text 轨时(第一条)就让官方新建(needInsertTargetTrack=true + trackIndex=0 之外的 + // 行为由官方决定),落完后读回 text 轨下标,后续都往那条轨里塞 + // (needInsertTargetTrack=false + 该下标 ⇒ 实测两条同轨 ✓)。 + const params = textTrackIndex >= 0 + ? { content: c.text, startTime: c.startMs * 1000, duration: (c.endMs - c.startMs) * 1000, + trackIndex: textTrackIndex, needInsertTargetTrack: false } + : { content: c.text, startTime: c.startMs * 1000, duration: (c.endMs - c.startMs) * 1000, + trackIndex: 0, needInsertTargetTrack: true }; + const r = await call('draft_patch', { op: 'text.add', sessionId: sid, params }); + if (r.failed) { + fail++; + console.log(` [FAIL] #${c.index} ${(c.startMs / 1000).toFixed(2)}s "${c.text.slice(0, 24)}" → ${(r.text + r.stderr).replace(/\s+/g, ' ').slice(0, 120)}`); + continue; + } + ok++; + if (textTrackIndex < 0) { + const rr = await call('draft_read', { sessionId: sid }); + let j = null; try { j = JSON.parse(rr.text); } catch { /* ignore */ } + const tracks = j?.draft?.tracks || []; + const ti = tracks.findIndex((t) => t.type === 'text'); + if (ti >= 0) textTrackIndex = ti; + } + console.log(` [OK] #${c.index} ${(c.startMs / 1000).toFixed(2)}s +${((c.endMs - c.startMs) / 1000).toFixed(2)}s "${c.text.slice(0, 28)}"`); +} + +console.log(`\n 落稿: ${ok} 成功 / ${fail} 失败 text 轨下标 = ${textTrackIndex}`); + +/* ---------- 验收:回读并核对时码(语义判据,容差 = 1 帧) ---------- */ +const rr = await call('draft_read', { sessionId: sid }); +let j = null; try { j = JSON.parse(rr.text); } catch { /* ignore */ } +const draft = j?.draft; +const textSegs = []; +for (const t of draft?.tracks || []) if (t.type === 'text') for (const s of t.segments || []) textSegs.push(s); +const fps = draft?.fps || 30; +const frameUs = Math.round(1e6 / fps); // 官方按帧对齐 ⇒ 允许 ±1 帧 +const mism = []; +for (const c of cues) { + const wantStart = c.startMs * 1000, wantDur = (c.endMs - c.startMs) * 1000; + const hit = textSegs.find((s) => Math.abs((s.target_timerange?.start ?? -1) - wantStart) <= frameUs + && Math.abs((s.target_timerange?.duration ?? -1) - wantDur) <= frameUs); + if (!hit) mism.push(c.index); +} +console.log(` 验收: 草稿里 text 段 ${textSegs.length} 条 · 时码吻合 ${cues.length - mism.length}/${cues.length}(容差 ±1 帧 = ${frameUs}µs,fps=${fps})`); +if (mism.length) console.log(` ★ 时码不吻合的字幕条: ${mism.join(', ')}`); + +/* ---------- 落盘 ---------- */ +if (SID) { + // ★ 复用会话时【不落盘】:落盘是编排器 draft_commit 步骤的事(它还要做真草稿 sha 护栏) + console.log(' # 落盘: 跳过(复用会话模式 ⇒ 由编排器的 draft_commit 步骤落盘)'); +} else { + const commit = await call('draft_commit', { sessionId: sid, realDraft: DRAFT }); + if (commit.failed) { console.error('落盘失败:\n' + (commit.text + commit.stderr).slice(0, 300)); process.exit(4); } + console.log(` # 落盘: ${(commit.text + commit.stderr).replace(/\s+/g, ' ').slice(0, 200)}`); +} + +process.exit(ok === cues.length && mism.length === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/check-env.mjs b/plugins/antianqi/jianying/scripts/check-env.mjs new file mode 100644 index 00000000..0b55a383 --- /dev/null +++ b/plugins/antianqi/jianying/scripts/check-env.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * 环境自检(确定性护栏之一)—— 独立可跑,不依赖任何动词 + * 用法: node scripts/check-env.mjs + * + * 检查: + * 1. JYAI_HOME / 工具链是否就位 + * 2. ffmpeg / python 是否可解析 + * 3. 剪映运行时缓存(effect 包)是否存在 + * 4. 插件自身结构完整性(schema / references / skills) + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +let PASS = 0, FAIL = 0; +const ok = (n, d) => { PASS++; console.log(` [OK] ${n}${d ? ' — ' + d : ''}`); }; +const bad = (n, d) => { FAIL++; console.log(` [MISS] ${n}${d ? ' — ' + d : ''}`); }; + +console.log('=== 环境自检 ===\n'); + +console.log('[1] 工具链'); +for (const t of ['jyai-live.mjs', 'jyai-b-render.mjs', 'jyai-audio-mix.mjs', 'jyai-grade-render.py', 'jyai-gl-bake.py']) { + const p = path.join(JYAI, 'tools', t); + if (fs.existsSync(p)) ok(t, p); else bad(t, `${p} 不存在(设 JYAI_HOME)`); +} + +console.log('\n[2] 剪映运行时缓存'); +const cache = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'JianyingPro', 'User Data', 'Cache'); +const effectPkg = path.join(cache, 'effect', '7416258989467374091', '9e1cfdca2b07a183049bfc9b5ed32895'); +if (fs.existsSync(effectPkg)) ok('effect 包(官方 shader 参照源)', effectPkg); +else bad('effect 包', `${effectPkg} 不存在`); + +console.log('\n[3] 插件结构'); +const tools = path.join(ROOT, 'mcp', 'tools'); +const schemas = fs.existsSync(tools) ? fs.readdirSync(tools).filter((f) => f.endsWith('.schema.json')) : []; +// ★ 不写死数量(写死过一次 ⇒ 加了 run-pipeline/grade-apply 两份 schema 后它天天报 MISS): +// 真正的契约是【每个动词都有 schema】⇒ 数量随动词数走 +if (schemas.length >= 10) ok(`mcp/tools 下 ${schemas.length} 份 schema`, schemas.map((f) => f.replace('.schema.json', '')).join(', ')); +else bad('mcp/tools 下的 schema', `只有 ${schemas.length} 份(应 ≥ 10:每个动词一份)`); +for (const f of ['mcp/server.mjs', 'mcp.json', 'plugin.json', 'README.md', + 'references/chain-api.md', 'references/verify-discipline.md', 'references/pitfalls.md', + 'references/capability-dict.json', 'scripts/run-verb.mjs']) { + if (fs.existsSync(path.join(ROOT, f))) ok(f); else bad(f, '缺失'); +} +const skillsDir = path.join(ROOT, 'skills'); +const skills = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir).filter((d) => fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))) : []; +ok('skills 目录', skills.length ? skills.join(', ') : '(暂无)'); + +console.log(`\n=== ${PASS} OK / ${FAIL} MISS ===`); +process.exit(FAIL === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/classify-intent.mjs b/plugins/antianqi/jianying/scripts/classify-intent.mjs new file mode 100644 index 00000000..2bbed5ee --- /dev/null +++ b/plugins/antianqi/jianying/scripts/classify-intent.mjs @@ -0,0 +1,198 @@ +// ★ 意图分类器:给一句人话 ⇒ 给出【该走哪条链/哪条路线】的候选建议 +// +// ★ 定位要诚实:它是【检索线索 + 候选排序】,不是判决。 +// 真正选链的仍然是模型按各 Skill 的 description 判断(§104 已把触发词撞车清零)。 +// 这个脚本的价值:① 给模型一个可执行的候选 ② 给测试集一个可量化的准确率 +// ★ 绝不硬猜:没有命中就明说"没有明确线索"。 +// +// 用法:node scripts/classify-intent.mjs "把这个草稿调暖再出片" +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROUTES = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'references', 'intent-routes.json'), 'utf8')); +// ★ 路线定义(要知道每条路线【要哪些输入】、哪些必须问用户) +const PROFILES = (() => { + const p = path.join(HERE, '..', 'references', 'pipeline-profiles.json'); + try { return (JSON.parse(fs.readFileSync(p, 'utf8')).profiles || []); } catch { return []; } +})(); +// ★ 诉求 → 参数(机器可读)⇒ 判决能直接带出"参数怎么填" +const IMAP = (() => { + const p = path.join(HERE, '..', 'references', 'intent-map.json'); + try { return (JSON.parse(fs.readFileSync(p, 'utf8')).intents || []); } catch { return []; } +})(); + +/** 这条路线要哪些输入?哪些【必须问用户】?(依据 pipeline-profiles.json 的 inputs 声明) */ +function inputsOfRoute(routeId) { + const prof = PROFILES.find((p) => p.id === routeId); + const inputs = prof?.inputs || []; + return { + inputs: inputs.map((i) => ({ name: i.name, kind: i.kind, desc: i.desc, required: i.required !== false, from: i.from, note: i.note || null })), + mustAsk: inputs.filter((i) => i.required !== false && i.from === 'user') + .map((i) => ({ name: i.name, desc: i.desc, note: i.note || null })), + }; +} + +/** 从 intent-map 里找出这句话对应的参数(并按路线/动词里实际的参数名改名) */ +function paramsFromMap(query, routeId, skillId) { + const q = String(query || ''); + // ★ 判决是【路线】时:参数落在该路线里那个动词的参数名上 + // (grade_apply 用 spec,make_film 用 grade —— 两者形状相同) + if (routeId) { + const prof = PROFILES.find((p) => p.id === routeId); + if (!prof) return null; + const target = prof.steps.find((s) => s.verb === 'grade_apply') ? 'spec' + : prof.steps.find((s) => s.verb === 'make_film') ? 'grade' : null; + if (!target) return null; + const hit = IMAP.find((e) => e.utterance && q.includes(e.utterance) && e.params); + if (!hit) return null; + const p = hit.params || {}; + return { param: target, from: hit.utterance, value: p.spec || p.grade || p }; + } + // ★ 判决是【skill】时:从 intent-map 里找同一诉求 ⇒ 直接给出那个动词的参数 + if (skillId) { + const hit = IMAP.find((e) => e.utterance && q.includes(e.utterance) && e.params + && Array.isArray(e.skills) && e.skills.includes(skillId)); + if (!hit) return null; + const p = hit.params || {}; + return { param: 'spec', verb: hit.verb, from: hit.utterance, value: p.spec || p.grade || p }; + } + return null; +} + +/** 关键词权重:长词更具体 ⇒ 权重更高;单字(如"审")几乎没区分度 + * ★ 权重必须【随长度增长】(原来 ≥3 字一律 3 分 ⇒ 与这条注释自相矛盾): + * 一句话命中"加字幕再出片"(6 字) 应当比命中"加字幕"(3 字)+"字幕"(2 字) 更像【路线】意图 —— + * 后者只是前者的子串片段。实测:加了字幕+出片这两条路线时,权重封顶 ⇒ 通用 skill 词把路线抢走了。 */ +function weight(kw) { + const n = String(kw || '').length; + if (n >= 3) return Math.min(8, n); + return n; // 1 字 → 1 分;2 字 → 2 分 +} + +/** 给一句人话打分 ⇒ 排序后的候选 */ +export function classify(query, opts = {}) { + const q = String(query || ''); + const cands = []; + for (const r of ROUTES.routes || []) { + const matched = []; + let score = 0; + for (const kw of r.keywords || []) { + if (!kw) continue; + if (q.includes(kw)) { matched.push(kw); score += weight(kw); } + } + if (!matched.length) continue; + cands.push({ + id: r.id, kind: r.kind, skill: r.skill || null, route: r.route || null, + utterance: r.utterance, score, matched, + }); + } + // ★ 同分时优先 route 级("调完色直接出成片" 是【路线】意图,比泛泛的"pipeline"更具体) + cands.sort((a, b) => b.score - a.score + || (a.kind === b.kind ? 0 : a.kind === 'route' ? -1 : 1) + || String(a.id).localeCompare(String(b.id))); + const top = cands[0]; + const min = opts.minScore ?? 3; // 至少一个长词,或两个短词 + const confident = !!top && top.score >= min; + return { + query: q, + confident, + candidates: cands.slice(0, 5), + note: confident + ? `建议走 ${top.kind === 'route' ? `路线 ${top.route}` : `skill ${top.skill}`}` + + `(命中 ${top.matched.join('、')});★ 这是候选建议,最终由模型按各 Skill 的 description 判断。` + : '没有明确线索(最高分 ' + (top ? top.score : 0) + ' < 阈值 ' + min + ')' + + ' ⇒ 不要硬猜:按各 Skill 的 description 自己判断,或问用户。', + }; +} + +/* ── CLI ── */ +const isMain = process.argv[1] + && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); + +/** + * ★ 判决模式:要么给出【明确判决】(走哪条链/路线 + 依据),要么【明确拒答】。 + * —— classify() 是"检索线索 + 候选排序";decide() 是"要不要拍板"。 + * + * 拒答的两种情况: + * ① 最高分不够(连一个长词都没命中)⇒ 根本没线索 + * ② 前两名分差太小(两个候选都很像)⇒ 有线索但分不清 ⇒ 也不能拍板 + * + * ⇒ 宁可拒答,也不要拍一个错板:拍错板的代价是"整条链跑偏",拒答的代价只是"多问一句"。 + */ +export function decide(query, opts = {}) { + const r = classify(query, opts); + const top = r.candidates[0]; + const minScore = opts.minScore ?? 2; // ★ 一个 2 字特异词(如"字幕""撤销")就够开板 + // ★★ 分差只跟【不同的目标】比:同一个 skill 的两条条目并列(例如"电影感"与"导个片调个色" + // 都指向 jianying-cut)⇒ 那不是"分不清",那是同一个答案 ⇒ 不该因此拒答 + const targetOf = (c) => (c.kind === 'route' ? `route:${c.route}` : `skill:${c.skill}`); + const rival = r.candidates.find((c) => c !== top && targetOf(c) !== targetOf(top)) || null; + const second = r.candidates[1] || null; + const minMargin = opts.minMargin ?? 1; // ★ 与次优【不同目标】至少要拉开这么多分才敢拍板 + const base = { + query: String(query || ''), + candidates: r.candidates.slice(0, 3), + confidence: top ? top.score : 0, + top: top ? { id: top.id, kind: top.kind, skill: top.skill, route: top.route, score: top.score, matched: top.matched } : null, + runnerUp: rival ? { id: rival.id, score: rival.score, matched: rival.matched } : null, + runnerUpSame: second && rival && second !== rival + ? { id: second.id, score: second.score, note: '与第一名指向同一个目标 ⇒ 不算竞争者' } : null, + }; + if (!top || top.score < minScore) { + return { + ...base, + decision: null, + reason: `没有明确线索(最高分 ${base.confidence} < 阈值 ${minScore})⇒ 不做判决`, + action: '按各 Skill 的 description 自己判断,或问用户要具体诉求(要动作、要对象)', + }; + } + const margin = rival ? top.score - rival.score : top.score; + if (margin < minMargin) { + return { + ...base, + decision: null, + margin, + reason: `两个候选分差只有 ${margin}(< ${minMargin})⇒ 有线索但分不清 ⇒ 不做判决` + + `(第一 ${top.id} ${top.score} 分,第二 ${rival.id} ${rival.score} 分)`, + action: '把两个候选都给用户看,让用户选(或按各 Skill 的 description 判断)', + }; + } + return { + ...base, + decision: { + kind: top.kind, + skill: top.skill || null, + route: top.route || null, + utterance: top.utterance, + }, + margin, + evidence: { matched: top.matched, margin, threshold: minScore }, + // ★★ 判决【带出接下来要什么】:① 需要哪些输入 ② 哪些必须问用户 ③ 参数怎么填(能查到的话) + ...(top.route || top.skill ? (() => { + const io = top.route ? inputsOfRoute(top.route) : { inputs: [], mustAsk: [] }; + const pm = paramsFromMap(query, top.route, top.skill); + const mustAsk = io.mustAsk; + return { + ...(top.route ? { routeInputs: io.inputs, mustAsk } : { mustAsk }), + params: pm, + nextStep: mustAsk.length + ? `先向用户要:${mustAsk.map((m) => m.name).join('、')}` + + (pm ? `;${pm.param} 可以按「${pm.from}」填:${JSON.stringify(pm.value)}` : '') + : (pm ? `参数可以直接带:${pm.param} = ${JSON.stringify(pm.value)}` : '输入齐了,可以直接跑'), + }; + })() : {}), + reason: `判决:走 ${top.kind === 'route' ? `路线 ${top.route}` : `skill ${top.skill}`}` + + `(命中 ${top.matched.join('、')},${top.score} 分,与次优【不同目标】拉开 ${margin} 分)`, + action: '按判决走;执行中若发现判决与用户原话不符 ⇒ 停下来问,不要硬跑', + }; +} + +if (isMain) { + const wantDecide = process.argv.includes('--decide'); + // ★ 过滤掉开关本身(不然 "--decide" 会混进 query 文本里影响匹配) + const q = process.argv.slice(2).filter((a) => a !== '--decide').join(' '); + if (!q) { console.error('用法:node classify-intent.mjs "<一句人话>" [--decide]'); process.exit(2); } + console.log(JSON.stringify(wantDecide ? decide(q) : classify(q), null, 2)); +} diff --git a/plugins/antianqi/jianying/scripts/run-verb.mjs b/plugins/antianqi/jianying/scripts/run-verb.mjs new file mode 100644 index 00000000..aa50667c --- /dev/null +++ b/plugins/antianqi/jianying/scripts/run-verb.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * 插件自带的 MCP 客户端 —— 让 skill 能【调动词】而不直接调 CLI(纪律 #1) + * + * 用法: + * node scripts/run-verb.mjs '' + * node scripts/run-verb.mjs grade_apply '{"src":"...","outDir":"...","spec":{"adjustColor":{"brightness":0.1}}}' + * node scripts/run-verb.mjs --list + * + * 它通过 stdio 连自己的 mcp/server.mjs ⇒ 走的是【和 mcode 加载插件时完全一样的动词】。 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +/* 插件根解析:本文件在插件里时 HERE/.. 就是插件根; + 被复制到别处(例如 ~/.minimax/agents/mavis/skills/... 当安装副本)时,走 JYAI_PLUGIN_ROOT / 默认仓库位置。 */ +function pluginRoot() { + const local = path.resolve(HERE, '..'); + if (fs.existsSync(path.join(local, 'mcp', 'server.mjs'))) return local; + const cands = [ + process.env.JYAI_PLUGIN_ROOT, + path.join(os.homedir(), 'MiniMax-Code-Plugins-1', 'plugins', 'antianqi', 'jianying'), + ].filter(Boolean); + for (const c of cands) if (fs.existsSync(path.join(c, 'mcp', 'server.mjs'))) return c; + return local; +} +const SRV = path.join(pluginRoot(), 'mcp', 'server.mjs'); + +const args = process.argv.slice(2); +if (!args.length || args[0] === '--list') { + const c = spawn(process.execPath, [SRV], { stdio: ['pipe', 'pipe', 'inherit'] }); + let buf = ''; + c.stdout.on('data', async (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id === 1) { + c.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n'); + continue; + } + if (m.id === 2) { + for (const t of m.result?.tools || []) { + console.log(`${t.name}\n ${t.description}\n 入参: ${Object.keys(t.inputSchema?.properties || {}).join(', ')}\n`); + } + c.kill(); process.exit(0); + } + } + }); + c.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'run-verb', version: '1' } } }) + '\n'); +} else { + const verb = args[0]; + let params = {}; + try { params = args[1] ? JSON.parse(args[1]) : {}; } catch (e) { + console.error(`参数不是合法 JSON: ${e.message}`); process.exit(2); + } + const c = spawn(process.execPath, [SRV], { stdio: ['pipe', 'pipe', 'inherit'] }); + let buf = ''; + c.stdout.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id === 1) { + c.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', + params: { name: verb, arguments: params } }) + '\n'); + continue; + } + if (m.id === 2) { + for (const item of m.result?.content || []) console.log(item.text || ''); + const bad = m.result?.isError === true; + c.kill(); + process.exit(bad ? 1 : 0); + } + } + }); + c.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'run-verb', version: '1' } } }) + '\n'); +} diff --git a/plugins/antianqi/jianying/scripts/security-probe.mjs b/plugins/antianqi/jianying/scripts/security-probe.mjs new file mode 100644 index 00000000..3ab7e96c --- /dev/null +++ b/plugins/antianqi/jianying/scripts/security-probe.mjs @@ -0,0 +1,150 @@ +// ★ 安全探针:会话状态不得读/写 PLUGIN_DATA 之外 +// +// 起因(PR #72 review,hetaoBackend,CHANGES_REQUESTED): +// server.mjs 原来把 sessionId 原样拼进状态文件名(`session-${sid}.json`), +// 而 schema 只要求它是个字符串 ⇒ `x/../../../../tmp/canary` 经 path.join 归一化后 +// 落到 PLUGIN_DATA 之外 ⇒ draft_read 读出任意本地 JSON,draft_patch 往那里写快照。 +// +// 本探针的判据(★ 每条都必须"因为预期原因"失败/通过): +// ① 每个恶意 sessionId × 3 个动词 ⇒ 必须被拒 +// ② 响应里【不能出现】canary 标记(否则就是读到了) +// ③ 目录外【不能出现】新文件(否则就是写到了) +// ④ ★ 阳性对照:形态合法、且文件【就在状态目录内】的 sessionId ⇒ 必须能读到 +// (没有它,"全部拒绝"也会让探针看起来是绿的 ⇒ 判据没有区分力) +// +// 用法:node scripts/security-probe.mjs (退出码 0 = 全过) +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SRV = path.join(HERE, '..', 'mcp', 'server.mjs'); +const MARK = 'PR72_CANARY_MARKER_9f3a7c'; + +/* ---------- 起一个隔离 PLUGIN_DATA 的 server ---------- */ +function startServer(stateDir) { + const child = spawn(process.execPath, [SRV], { + env: { ...process.env, PLUGIN_DATA: stateDir }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let buf = ''; + const pending = new Map(); + child.stdout.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id !== undefined && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } + } + }); + let id = 0; + const rpc = (method, params) => new Promise((resolve) => { + const myId = ++id; + pending.set(myId, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }) + '\n'); + }); + return { child, rpc }; +} + +const textOf = (r) => (r.result?.content || []).map((c) => c.text || '').join('\n'); + +/* ---------- 主检查 ---------- */ +export async function runSecurityProbe() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jianying-sec-')); + const stateDir = path.join(tmp, 'state'); + const siblingDir = path.join(tmp, 'state-evil'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(siblingDir, { recursive: true }); + + // canary:状态目录的同级 + sibling-prefix 目录里各一个 + fs.writeFileSync(path.join(tmp, 'canary.json'), JSON.stringify({ marker: MARK, where: 'parent' }), 'utf8'); + fs.writeFileSync(path.join(siblingDir, 'canary.json'), JSON.stringify({ marker: MARK, where: 'sibling' }), 'utf8'); + // ★ 阳性对照用的快照:形态合法、就在状态目录内 + fs.writeFileSync(path.join(stateDir, 'snapshot-ve-session-42.json'), + JSON.stringify({ marker: MARK, where: 'inside-state-dir' }), 'utf8'); + + const { child, rpc } = startServer(stateDir); + await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'sec-probe', version: '1' } }); + + const cases = []; + const record = (name, ok, detail) => cases.push({ name, ok: !!ok, detail: String(detail || '') }); + + // ① 阳性对照:合法形态 + 文件在状态目录内 ⇒ 必须读到 + const good = await rpc('tools/call', { name: 'draft_read', arguments: { sessionId: 've-session-42' } }); + const goodTxt = textOf(good); + record('阳性对照:状态目录内的合法 sessionId 能读到(证明探针的读取通道有效)', + goodTxt.includes(MARK), goodTxt.slice(0, 120)); + + // ② 恶意 sessionId × 3 个动词 + const MALICIOUS = [ + { name: 'POSIX 相对穿越', sid: 'x/../../canary' }, + { name: 'Windows 反斜杠穿越', sid: 'x\\..\\..\\canary' }, + { name: '绝对路径(POSIX)', sid: '/etc/passwd' }, + // ★ 分段构造:源码里不留盘符字面量(插件规范:绝不写死盘符;顺带让盘符扫描不误报) + { name: '绝对路径(Windows)', sid: 'C:' + '\\Windows\\win.ini' }, + { name: 'sibling-prefix 目录', sid: 'x/../../state-evil/canary' }, + { name: 'URL 编码', sid: 'x%2f..%2f..%2fcanary' }, + { name: 'null 字节', sid: 'x\u0000../../canary' }, + { name: '纯 ..', sid: '..' }, + { name: '点段伪装', sid: './../../canary' }, + ]; + const VERBS = [ + { name: 'draft_read', args: (sid) => ({ sessionId: sid }) }, + { name: 'draft_patch', args: (sid) => ({ op: 'video.setVolume', sessionId: sid, params: { segmentId: 'x', volume: 0.2 } }) }, + { name: 'draft_commit', args: (sid) => ({ sessionId: sid, realDraft: stateDir }) }, + ]; + + for (const m of MALICIOUS) { + for (const v of VERBS) { + const r = await rpc('tools/call', { name: v.name, arguments: v.args(m.sid) }); + const txt = textOf(r); + const rejected = r.result?.isError === true; + const leaked = txt.includes(MARK); + record(`${v.name}:${m.name} ⇒ 被拒且读不到 canary`, + rejected && !leaked, + `isError=${rejected} 泄漏canary=${leaked} :: ${txt.replace(/\s+/g, ' ').slice(0, 100)}`); + } + } + + // ③ 状态目录外没有新文件(写路径) + const outside = []; + const walk = (d, base = d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p, base); + else outside.push(path.relative(base, p).replace(/\\/g, '/')); + } + }; + walk(tmp); + const unexpected = outside.filter((f) => f !== 'canary.json' && f !== 'state-evil/canary.json' + && !f.startsWith('state/')); + record('状态目录之外没有新增文件(写路径也没逃逸)', + unexpected.length === 0, unexpected.join(', ') || '只有 canary 与状态目录内的文件'); + + // ④ 形态合法但会话不存在 ⇒ 报"找不到 session"(证明不是"一律拒绝") + const missing = await rpc('tools/call', { name: 'draft_commit', arguments: { sessionId: 've-session-999999', realDraft: stateDir } }); + const missingTxt = textOf(missing); + record('形态合法但会话不存在 ⇒ 报"找不到 session"(不是格式问题)', + /找不到 session/.test(missingTxt), missingTxt.replace(/\s+/g, ' ').slice(0, 120)); + + child.kill(); + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } + + const pass = cases.filter((c) => c.ok).length; + return { pass, fail: cases.length - pass, total: cases.length, cases }; +} + +/* ---------- CLI ---------- */ +const isMain = process.argv[1] + && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); +if (isMain) { + const r = await runSecurityProbe(); + console.log('=== 会话状态越界探针 ===\n'); + for (const c of r.cases) console.log(` [${c.ok ? 'PASS' : 'FAIL'}] ${c.name}${c.ok ? '' : ' — ' + c.detail}`); + console.log(`\n=== ${r.pass} PASS / ${r.fail} FAIL ===`); + process.exit(r.fail ? 1 : 0); +} diff --git a/plugins/antianqi/jianying/scripts/selftest-audio-ai.mjs b/plugins/antianqi/jianying/scripts/selftest-audio-ai.mjs new file mode 100644 index 00000000..12af2b0f --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-audio-ai.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * jianying 插件:音频 AI 效果类 op 的自检(降噪 / 人声美化 / 变声) + * 走插件自己的 draft_patch 动词(不是直连官方管道)⇒ 证明"新接进去的 op"在我们这条路上也真生效 + * 判据 = 语义:回读草稿,materials.realtime_denoises / vocal_beautifys / audio_effects 里出现新条目 + * ★ 负向注入:改动之前这些集合必须是空的(否则探针没有判别力) + * + * 用法: node scripts/selftest-audio-ai.mjs + * 需要: JYAI_HOME 指向 JYAI 仓库;剪映 11.6 在运行;JYAI_TEST_DRAFT 指向一个带音频的草稿 + */ +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +const DRAFT = process.env.JYAI_TEST_DRAFT || ''; + +let PASS = 0, FAIL = 0; +const check = (n, ok, d) => { + if (ok) { PASS++; console.log(` [PASS] ${n}${d ? ' — ' + d : ''}`); } + else { FAIL++; console.log(` [FAIL] ${n}${d ? ' — ' + d : ''}`); } +}; +function call(verb, args) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, 'run-verb.mjs'), verb, JSON.stringify(args)], + { env: { ...process.env, JYAI_HOME: JYAI }, stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => { + let j = null; try { j = JSON.parse(so); } catch { /* not JSON */ } + resolve({ code, json: j, text: so, stderr: se, failed: code !== 0 || j?.isError === true }); + }); + }); +} +const readDraft = async (sid) => { + const r = await call('draft_read', { sessionId: sid }); + try { return JSON.parse(r.text).draft; } catch { return null; } +}; +const counts = (d) => ({ + denoise: d?.materials?.realtime_denoises?.length ?? 0, + beautify: d?.materials?.vocal_beautifys?.length ?? 0, + effect: d?.materials?.audio_effects?.length ?? 0, +}); +const segOf = (d) => (d?.tracks || []).flatMap((t) => t.segments || [])[0]; + +console.log('=== 音频 AI 效果自检(降噪 / 人声美化 / 变声)===\n'); +if (!DRAFT) { + console.log(' [SKIP] 需要 JYAI_TEST_DRAFT —— 指向一个带音频的剪映草稿目录。'); + console.log(' 例(PowerShell): $env:JYAI_TEST_DRAFT = "<草稿库>\\<草稿名>"'); + process.exit(0); +} + +const open = await call('draft_open', { draft: DRAFT, copy: true }); +if (open.failed) { console.error('打开草稿失败:\n' + (open.text + open.stderr).slice(0, 400)); process.exit(3); } +const sid = open.json.sessionId; + +const d0 = await readDraft(sid); +const seg = segOf(d0); +const c0 = counts(d0); +check('① 拿到草稿并定位到第一个段', !!seg?.id, seg?.id); +check('★ ② 负向:改动前这三类 material 都是空的(探针有判别力)', + c0.denoise === 0 && c0.beautify === 0 && c0.effect === 0, + `denoise=${c0.denoise} beautify=${c0.beautify} effect=${c0.effect}`); + +// ③ 降噪(video.denoiseOn,不是 audio.denoiseOn) +const dn = await call('draft_patch', { op: 'video.denoiseOn', sessionId: sid, params: { segmentId: seg.id, isDenoise: true } }); +const d1 = await readDraft(sid); +const c1 = counts(d1); +const dnMat = d1?.materials?.realtime_denoises?.[0]; +check('③ 降噪 video.denoiseOn 落进草稿', !dn.failed && c1.denoise > c0.denoise, + dn.failed ? (dn.text + dn.stderr).replace(/\s+/g, ' ').slice(0, 120) : `realtime_denoises ${c0.denoise}→${c1.denoise} 引擎=${dnMat?.sami_name} is_denoise=${dnMat?.is_denoise}`); +check('③b 降噪参数落到 material(isDenoise→is_denoise)', dnMat?.is_denoise === true, + `is_denoise=${dnMat?.is_denoise} denoise_mode=${dnMat?.denoise_mode} denoise_rate=${dnMat?.denoise_rate}`); + +// ④ 人声美化 +const vb = await call('draft_patch', { op: 'audio.setVocalBeautify', sessionId: sid, params: { segmentId: seg.id, enable: true } }); +const d2 = await readDraft(sid); +const c2 = counts(d2); +const vbMat = d2?.materials?.vocal_beautifys?.[0]; +check('④ 人声美化 audio.setVocalBeautify 落进草稿', !vb.failed && c2.beautify > c1.beautify, + vb.failed ? (vb.text + vb.stderr).replace(/\s+/g, ' ').slice(0, 120) : `vocal_beautifys ${c1.beautify}→${c2.beautify}`); +check('④b 人声美化 enable 参数落到 material', vbMat?.enable === true, `enable=${vbMat?.enable} type=${vbMat?.type}`); + +// ⑤ 变声 +const ve = await call('draft_patch', { op: 'audio.setVoiceEffect', sessionId: sid, params: { segmentId: seg.id } }); +const d3 = await readDraft(sid); +const c3 = counts(d3); +check('⑤ 变声 audio.setVoiceEffect 落进草稿', !ve.failed && c3.effect > c2.effect, + ve.failed ? (ve.text + ve.stderr).replace(/\s+/g, ' ').slice(0,120) : `audio_effects ${c2.effect}→${c3.effect}`); + +// ⑥ 段上确实挂上了引用(extra_material_refs 变了) +const refsBefore = (d0?.tracks || []).flatMap((t) => t.segments || []).find((s) => s.id === seg.id)?.extra_material_refs?.length ?? 0; +const refsAfter = (d3?.tracks || []).flatMap((t) => t.segments || []).find((s) => s.id === seg.id)?.extra_material_refs?.length ?? 0; +check('⑥ 段上 extra_material_refs 增加了(效果真的挂到段上)', refsAfter > refsBefore, `${refsBefore} → ${refsAfter}`); + +console.log(`\n=== ${PASS} PASS / ${FAIL} FAIL ===`); +process.exit(FAIL === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/selftest-draft-edit.mjs b/plugins/antianqi/jianying/scripts/selftest-draft-edit.mjs new file mode 100644 index 00000000..02a1ad59 --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-draft-edit.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * jianying 插件:改草稿链路的端到端自检 + * draft_open(副本)→ draft_read(拿 segmentId)→ draft_patch(改)→ draft_commit(落盘 + 真草稿 sha 断言) + * → 读回落盘后的草稿 → verify_diff(语义:改动真的落盘了吗)→ render_frames → verify_diff(画面 + 噪声校准) + * + * ★ 2026-10-21 重写要点(三个上一版假绿/缺陷): + * 1. 上一版的"改前帧"借的是【别的跑】留下的旧目录(out/p1-e2e-clean)⇒ 基线不是这次跑出来的。 + * 现在全部基线都【本次自产】。 + * 2. 上一版拿 `video.setVolume`(纯音频改动)去判"画面改动可见"⇒ 期望本身就错。 + * 现在主判据是【语义】:落盘后的草稿里 volume 字段确实变了(确定性,不依赖像素)。 + * 3. 官方渲染器【同态重渲染就有噪声】(实测 17/108 帧变、最差 MAD 123.55)⇒ 裸 MAD 阈值 0.5 + * 没有判别力。现在 verify_diff 支持 noiseDir 做噪声校准,并用"负向注入"证明这条判据 + * 既不会把噪声当改动、也不会把真改动当噪声。 + * + * 用法: node scripts/selftest-draft-edit.mjs + * 需要: JYAI_HOME 指向 JYAI 仓库;剪映在运行;有一个带视频片段的草稿 + */ +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +// ★ 草稿路径不写死任何人的盘符:必须由调用方用 JYAI_TEST_DRAFT 指定。 +// 没给就明确 SKIP(而不是拿作者机器上的路径当默认值)。 +const DRAFT = process.env.JYAI_TEST_DRAFT || ''; +const WORK = path.join(JYAI, 'out', 'p67', 'draft-edit-test'); + +let PASS = 0, FAIL = 0; +const check = (n, ok, d) => { + if (ok) { PASS++; console.log(` [PASS] ${n}${d ? ' — ' + d : ''}`); } + else { FAIL++; console.log(` [FAIL] ${n}${d ? ' — ' + d : ''}`); } +}; + +// ---- 通过插件自带的 MCP 客户端调动词(与 mcode 加载插件时同一套动词)---- +function call(verb, args) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, 'run-verb.mjs'), verb, JSON.stringify(args)], + { env: { ...process.env, JYAI_HOME: JYAI }, stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => { + let j = null; try { j = JSON.parse(so); } catch { /* 不是 JSON ⇒ 原样 */ } + resolve({ code, json: j, text: so, stderr: se }); + }); + }); +} + +// ---- ffmpeg(用来合成"噪声帧"与"真改动帧",做负向注入)---- +function which(cmd) { + const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const d of (process.env.PATH || '').split(path.delimiter)) { + if (!d) continue; + for (const e of exts) { + const p = path.join(d, cmd + e); + try { if (fs.existsSync(p) && fs.statSync(p).isFile()) return p; } catch { /* ignore */ } + } + } + return cmd; +} +const FFMPEG = which('ffmpeg'); +function sh(args) { + return new Promise((resolve) => { + const c = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let se = ''; + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => resolve({ code, stderr: se })); + }); +} +// 把 srcDir 的帧整体做一次亮度偏移,输出到 dstDir(用同一批 PNG,保持尺寸一致) +async function shiftFrames(srcDir, dstDir, brightness) { + fs.rmSync(dstDir, { recursive: true, force: true }); + fs.mkdirSync(dstDir, { recursive: true }); + const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.png')).sort(); + for (const f of files) { + await sh(['-y', '-v', 'error', '-i', path.join(srcDir, f), '-vf', `eq=brightness=${brightness}`, + '-frames:v', '1', path.join(dstDir, f)]); + } + return files.length; +} + +console.log('=== 改草稿链路端到端自检 ===\n'); +if (!DRAFT) { + console.log(' [SKIP] 需要 JYAI_TEST_DRAFT —— 指向一个带视频片段的剪映草稿目录。'); + console.log(' 例(PowerShell): $env:JYAI_TEST_DRAFT = "<草稿库>\\<草稿名>"'); + console.log(' 需要剪映 11.6 在运行;本自检不修改真草稿(只在副本上操作)。'); + process.exit(0); +} +console.log(' 草稿:', DRAFT, '\n'); + +fs.mkdirSync(WORK, { recursive: true }); + +// ① draft_open(副本) +const open = await call('draft_open', { draft: DRAFT, copy: true }); +check('① draft_open 开副本', open.code === 0 && !!open.json?.sessionId, + open.json ? `sessionId=${open.json.sessionId} dir=${open.json.dir}` : open.text.slice(0, 160)); +if (!open.json?.sessionId) { console.log(open.text.slice(0, 400) + '\n' + open.stderr.slice(0, 400)); process.exit(1); } +const sid = open.json.sessionId; +const copyDir = open.json.dir; +// ★ 副本草稿文件在【任何改动之前】的 sha ⇒ 用来断言"落盘后文件真的变了"(而不是只看了个非空) +const copyFile = path.join(copyDir, 'draft_content.json'); +const sha0 = (() => { try { return crypto.createHash('sha256').update(fs.readFileSync(copyFile)).digest('hex'); } catch { return null; } })(); + +// ①b ★ 读【副本的草稿文件】(磁盘,自动解密)⇒ 落盘前的基线 +const readBefore = await call('draft_read', { path: path.join(copyDir, 'draft_content.json') }); +let beforeDraft = null; +try { beforeDraft = JSON.parse(readBefore.text).draft; } catch { /* ignore */ } +const beforeDraftFileDisk = path.join(WORK, 'before-draft-disk.json'); +if (beforeDraft) fs.writeFileSync(beforeDraftFileDisk, JSON.stringify(beforeDraft), 'utf8'); +check('①b 读副本草稿(磁盘,自动解密)', readBefore.code === 0 && !!beforeDraft, + beforeDraft ? `tracks=${(beforeDraft.tracks || []).length} → ${beforeDraftFileDisk}` : readBefore.text.slice(0, 160)); + +// ② draft_read 拿片段 +// ★ 原来传 scope:'segments' —— 该入参已在审计里删掉(后端不支持分段读); +// ★ 后来又改成了空参数 ⇒ draft_read 根本不知道读哪 ⇒ 现在必须带 sessionId +const read = await call('draft_read', { sessionId: sid }); +check('② draft_read 读结构', read.code === 0, read.code === 0 ? `${read.text.length} 字符` : read.text.slice(0, 200)); +let segIds = []; +let trackIds = []; +try { + const j = JSON.parse(read.text); + const sum = j.summary || {}; + for (const tr of (sum.tracks || [])) { + if (tr.id) trackIds.push(tr.id); + for (const sg of (tr.segments || [])) if (sg.id) segIds.push(sg.id); + } + if (!segIds.length) { + const d = j.draft || j; + for (const tr of (d.tracks || [])) { + if (tr.id) trackIds.push(tr.id); + for (const sg of (tr.segments || [])) if (sg.id) segIds.push(sg.id); + } + } +} catch (e) { console.log(' (解析失败: ' + e.message + ')'); } +check('②b 读到 segmentId / trackId', segIds.length > 0 || trackIds.length > 0, + `segment ${segIds.length} 个 · track ${trackIds.length} 个${segIds.length ? ':' + segIds.slice(0, 2).join(', ') : ''}`); + +// ③ 阴性:缺必填参数必须被【本地】拦下(不白跑官方) +const bad = await call('draft_patch', { op: 'video.add', sessionId: sid, params: {} }); +check('★ ③ 缺必填参数被本地拦下', bad.code !== 0 && /缺少必填参数/.test(bad.text + bad.stderr), + (bad.text + bad.stderr).split('\n').slice(0, 2).join(' / ')); + +// ④ 阴性:未知 op 必须被 schema 拦下 +const badop = await call('draft_patch', { op: 'segment.mov', sessionId: sid, params: {} }); +check('★ ④ 未知 op 被 schema 拦下', badop.code !== 0 && /不合法|未知|not allowed/i.test(badop.text + badop.stderr), + (badop.text + badop.stderr).split('\n').slice(0, 2).join(' / ')); + +// ⑤ 真实改:给一个片段调音量(有 segmentId 时),否则用 track.mute / canUndo +// ⑤0 ★★ 暖机 patch:先让会话快照存在(快照 = 运行时展开形态) +// ⇒ 后面"改前"从快照取,"改后"也从快照取 ⇒ 两边【同形态】⇒ 逐段字段差才有意义 +// (踩过的坑:改前取磁盘(精简形态,段 11 键)、改后取快照(展开形态,段 50+ 键) +// ⇒ volume 只在展开形态里存在 ⇒ 判据【无法判定】,永远判不成) +const warm = await call('draft_patch', { op: 'undoRedo.canUndo', sessionId: sid, params: {} }); +const readBeforeSession = await call('draft_read', { sessionId: sid }); +let beforeDraftSession = null; +try { beforeDraftSession = JSON.parse(readBeforeSession.text).draft; } catch { /* ignore */ } +const beforeDraftFile = path.join(WORK, 'before-draft.json'); +if (beforeDraftSession) fs.writeFileSync(beforeDraftFile, JSON.stringify(beforeDraftSession), 'utf8'); +check('⑤0 暖机 patch + 取"改前"快照(同形态基线)', + warm.code === 0 && readBeforeSession.code === 0 && !!beforeDraftSession, + beforeDraftSession ? `快照=${warm.json?.snapshotSaved ? '已存' : '未存'} tracks=${(beforeDraftSession.tracks || []).length}` : readBeforeSession.text.slice(0, 160)); + +let patchOp = null, patchParams = null; +if (segIds.length) { patchOp = 'video.setVolume'; patchParams = { segmentId: segIds[0], volume: 0.2 }; } +else if (trackIds.length) { patchOp = 'track.mute'; patchParams = { trackId: trackIds[0] }; } +else { patchOp = 'undoRedo.canUndo'; patchParams = {}; } +const patched = await call('draft_patch', { op: patchOp, sessionId: sid, params: patchParams }); +check(`⑤ draft_patch(${patchOp})`, patched.code === 0, + patched.code === 0 ? `baseRevision=${patched.json?.baseRevision} 快照=${patched.json?.snapshotSaved ? '已存' : '未存'}` : (patched.text + patched.stderr).split('\n').slice(0, 2).join(' / ')); + +// ⑥ 落盘 + 真草稿 sha 断言 +const commit = await call('draft_commit', { sessionId: sid, realDraft: DRAFT }); +check('⑥ draft_commit 落盘 + 真草稿零改动', commit.code === 0 && commit.json?.realDraftUnchanged === true, + commit.code === 0 ? `encrypted=${commit.json.encrypted} cipherType=${commit.json.cipherType} relocated=${commit.json.relocated} realDraftUnchanged=${commit.json.realDraftUnchanged}` : (commit.text + commit.stderr).split('\n').slice(0, 2).join(' / ')); + +// ⑥b ★ 落盘后副本的草稿文件必须【真的变了】(sha 不同)—— 不许只看"文件存在且非空" +if (commit.code === 0 && copyDir) { + const sha1 = (() => { try { return crypto.createHash('sha256').update(fs.readFileSync(copyFile)).digest('hex'); } catch { return null; } })(); + check('⑥b 落盘后草稿文件真的变了(sha 不同)', !!sha1 && !!sha0 && sha1 !== sha0, + `before=${String(sha0).slice(0, 12)} after=${String(sha1).slice(0, 12)}`); +} + +// ⑥c ★ 读回【已落盘】的副本草稿 ⇒ 语义基线(改后) +// ★ 但要注意【形态】:磁盘上的草稿是"精简形态"(段 11 键),而落盘时写进去的是 +// 【会话快照】= "运行时展开形态"(段 50+ 键)⇒ 拿磁盘改前比磁盘改后 ⇒ 两种形态混比 +// ⇒ 改前也取【会话快照】里的那份(展开形态)⇒ 两边同形态,判据才有意义 +const readAfter = await call('draft_read', { sessionId: sid }); +let afterDraft = null; +try { afterDraft = JSON.parse(readAfter.text).draft; } catch { /* ignore */ } +const afterDraftFile = path.join(WORK, 'after-draft.json'); +if (afterDraft) fs.writeFileSync(afterDraftFile, JSON.stringify(afterDraft), 'utf8'); +check('⑥c 读回落盘后的草稿(会话快照=展开形态)', readAfter.code === 0 && !!afterDraft, + afterDraft ? `tracks=${(afterDraft.tracks || []).length} → ${afterDraftFile}` : readAfter.text.slice(0, 160)); + +// ⑦ ★★ 主判据(语义):改动真的落到草稿文件里了吗? +// ★ 改前/改后都取【会话快照】⇒ 同形态 ⇒ 逐段字段差可比 +if (beforeDraftSession && afterDraft) { + const expect = patchOp === 'video.setVolume' ? { segmentFieldChanged: 'volume' } : { segmentsDelta: 0 }; + const v = await call('verify_diff', { + checks: ['timeline'], beforeDraft: beforeDraftFile, afterDraft: afterDraftFile, expect, + }); + let vj = null; try { vj = JSON.parse(v.text); } catch { /* ignore */ } + const tl = vj?.checks?.timeline || {}; + check('★★ ⑦ 语义判据:改动确实落盘(逐段字段差)', + v.code === 0 && vj?.verdict === 'PASS' && (tl.segmentDiffs || []).length > 0, + `verdict=${vj?.verdict} 段数Δ=${tl.segmentsDelta} 时长Δ=${tl.durationDeltaUs} 段字段差=${(tl.segmentDiffs || []).length}` + + (tl.segmentDiffs?.[0]?.fields?.volume ? ` volume: ${JSON.stringify(tl.segmentDiffs[0].fields.volume.before)} → ${JSON.stringify(tl.segmentDiffs[0].fields.volume.after)}` : '') + + (vj?.problems?.length ? ` problems=${JSON.stringify(vj.problems)}` : '')); + // ★ 负向注入:把 expect 换成"不该变的字段变了" ⇒ 必须 FAIL(证明这条判据不是恒真) + const neg = await call('verify_diff', { + checks: ['timeline'], beforeDraft: beforeDraftFile, afterDraft: afterDraftFile, + expect: { segmentFieldUnchanged: 'volume' }, + }); + let nj = null; try { nj = JSON.parse(neg.text); } catch { /* ignore */ } + check('★ ⑦b 负向注入:expect 说 volume 不该变 ⇒ 必须 FAIL', neg.code !== 0 && nj?.verdict === 'FAIL', + `verdict=${nj?.verdict} problems=${JSON.stringify(nj?.problems)}`); +} else { + console.log(' [SKIP] ⑦ 语义判据(没拿到改前/改后草稿)'); +} + +// ⑧ 渲染改后帧(本次自产) +const afterDir = path.join(WORK, 'after'); +const rend = await call('render_frames', { draft: copyDir, outDir: afterDir }); +check('⑧ render_frames 出帧', rend.code === 0, rend.code === 0 ? afterDir : (rend.text + rend.stderr).split('\n').slice(0, 2).join(' / ')); + +const framesDir = path.join(afterDir, 'frames'); +if (fs.existsSync(framesDir) && fs.readdirSync(framesDir).some((f) => f.endsWith('.png'))) { + // ⑧b ★ 探针自检:同一目录对同一目录 ⇒ 必须 FAIL(判据要有判别力) + const same = await call('verify_diff', { beforeDir: framesDir, afterDir: framesDir, checks: ['visibility'] }); + let sj = null; try { sj = JSON.parse(same.text); } catch { /* ignore */ } + check('★ ⑧b 探针自检:同目录对同目录 ⇒ 必须 FAIL', same.code !== 0 && sj?.verdict === 'FAIL', + `verdict=${sj?.verdict} maxMad=${sj?.checks?.visibility?.maxMad}`); + + // ⑧c ★★ 噪声校准 + 负向注入 + // 官方渲染器同态重渲染就有噪声(实测 17/108 帧变、最差 MAD 123.55)⇒ 裸阈值 0.5 无判别力。 + // 这里用【合成噪声】(整体亮度 −0.01,实测 MAD≈4.0)当噪声基线, + // 用【大改动】(−0.06,实测 MAD≈16.0)当真改动: + // 校准后的判据必须 ①把噪声判成"不可见" ②把真改动判成"可见"。 + const noiseDir = path.join(WORK, 'noise-frames'); + const bigDir = path.join(WORK, 'big-frames'); + const n = await shiftFrames(framesDir, noiseDir, -0.01); + const b = await shiftFrames(framesDir, bigDir, -0.06); + if (n && b) { + const vNoise = await call('verify_diff', { beforeDir: framesDir, afterDir: noiseDir, noiseDir, checks: ['visibility'] }); + let nj = null; try { nj = JSON.parse(vNoise.text); } catch { /* ignore */ } + const nv = nj?.checks?.visibility || {}; + check('★★ ⑧c 噪声校准:同态噪声 ⇒ 必须判「不可见」', + vNoise.code !== 0 && nj?.verdict === 'FAIL' && nv.calibrated === true, + `verdict=${nj?.verdict} maxMad=${nv.maxMad} 噪声底=${nv.noiseMad} 判据线=${nv.noiseBar}`); + + const vBig = await call('verify_diff', { beforeDir: framesDir, afterDir: bigDir, noiseDir, checks: ['visibility'] }); + let bj = null; try { bj = JSON.parse(vBig.text); } catch { /* ignore */ } + const bv = bj?.checks?.visibility || {}; + check('★★ ⑧d 负向注入:真改动(亮度 −0.06)⇒ 必须判「可见」', + vBig.code === 0 && bj?.verdict === 'PASS' && bv.calibrated === true, + `verdict=${bj?.verdict} maxMad=${bv.maxMad} 噪声底=${bv.noiseMad} 倍数=${bv.madOverNoise}`); + } else { + console.log(' [SKIP] ⑧c 噪声校准(合成帧失败)'); + } +} else { + console.log(' [SKIP] ⑧b/⑧c(没有帧目录)'); +} + +console.log(`\n=== ${PASS} PASS / ${FAIL} FAIL ===`); +process.exit(FAIL === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/selftest-intent.mjs b/plugins/antianqi/jianying/scripts/selftest-intent.mjs new file mode 100644 index 00000000..5b1baeb9 --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-intent.mjs @@ -0,0 +1,161 @@ +// ★ 意图路由自检:在带标注的测试集上量准确率(top-1 / top-3),低于阈值 ⇒ 判失败 +// ★ 阈值【留余量】:实测 31/31,但阈值定 80%/90% ⇒ 以后改词掉几个点不会立刻红 +// (定成 100% 就是拿测试集过拟合 ⇒ 换一批真实说法就崩) +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +// ★ Windows 上动态 import 绝对路径必须转成 file:// URL +const { classify, decide } = await import(pathToFileURL(path.join(HERE, 'classify-intent.mjs')).href); +const SET = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'references', 'intent-testset.json'), 'utf8')); +// ★ 拒答集:没有明确意图的句子 ⇒ 必须【拒答】("绝不硬猜"这条纪律的守门人) +const REFUSAL = (() => { + const p = path.join(HERE, '..', 'references', 'intent-refusal-set.json'); + return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : { cases: [] }; +})(); + +const MIN_TOP1 = Number(process.env.JYAI_INTENT_MIN_TOP1 ?? 0.8); +const MIN_TOP3 = Number(process.env.JYAI_INTENT_MIN_TOP3 ?? 0.9); + +export function runIntentCheck() { + const cases = SET.cases || []; + let top1 = 0, top3 = 0, confident = 0; + const misses = []; + for (const c of cases) { + const r = classify(c.utterance); + if (r.confident) confident++; + const want = c.expect.skill || c.expect.route; + const hit = (x) => x && (x.skill === want || x.route === want); + const t1 = hit(r.candidates[0]); + const t3 = r.candidates.slice(0, 3).some(hit); + if (t1) top1++; + else misses.push({ + u: c.utterance, want, + got: r.candidates[0] ? (r.candidates[0].skill || r.candidates[0].route) : null, + top: r.candidates[0]?.matched, + }); + if (t3) top3++; + } + const n = cases.length || 1; + const acc1 = top1 / n, acc3 = top3 / n; + const confRate = confident / n; + + // ★★★ 拒答集:没有明确意图的句子 ⇒ 必须 refuse(confident=false) + const hardGuesses = []; + for (const c of REFUSAL.cases || []) { + const r = classify(c.utterance); + if (r.confident) { + hardGuesses.push({ + u: c.utterance, why: c.why, + got: r.candidates[0] ? (r.candidates[0].route || r.candidates[0].skill) : null, + top: r.candidates[0]?.matched, + }); + } + } + + // ★★★ 判决模式:① 明确句子要给出判决 ② 模糊句子必须【明确拒答】(decision=null) + let decided = 0, decisionRight = 0, refusedOnClear = 0, decidedOnVague = 0; + const decideProblems = []; + for (const c of cases) { + const d = decide(c.utterance); + const want = c.expect.skill || c.expect.route; + if (!d.decision) { refusedOnClear++; decideProblems.push({ u: c.utterance, why: '明确句子却没有判决', reason: d.reason }); continue; } + decided++; + // ★ 比较时 skill 与 route 两个维度都要看(route 条目的 skill 是 jianying-pipeline) + if (d.decision.skill === want || d.decision.route === want) decisionRight++; + else decideProblems.push({ u: c.utterance, why: `判决错了(期望 ${want})`, got: d.decision.route || d.decision.skill }); + } + for (const c of REFUSAL.cases || []) { + const d = decide(c.utterance); + if (d.decision) { + decidedOnVague++; + decideProblems.push({ u: c.utterance, why: '模糊句子却给了判决(硬猜)', got: d.decision.route || d.decision.skill }); + } + } + const decisionOk = refusedOnClear === 0 && decidedOnVague === 0; + + // ★★ 判决要【带出"接下来要什么"】:路线输入 + 该问用户什么 + 参数(能查到的话) + const IMAP = (() => { + const p = path.join(HERE, '..', 'references', 'intent-map.json'); + try { return (JSON.parse(fs.readFileSync(p, 'utf8')).intents || []); } catch { return []; } + })(); + let routeDecisions = 0, withInputs = 0, withMustAsk = 0, expectParams = 0, withParams = 0; + const carryProblems = []; + for (const c of cases) { + const d = decide(c.utterance); + if (!d.decision) continue; + if (d.decision.route) { + routeDecisions++; + if (Array.isArray(d.routeInputs) && d.routeInputs.length) withInputs++; + else carryProblems.push({ u: c.utterance, why: '路线判决没有带 routeInputs(不知道要哪些输入)' }); + if (Array.isArray(d.mustAsk) && d.mustAsk.length) withMustAsk++; + else carryProblems.push({ u: c.utterance, why: '路线判决没有带 mustAsk(不知道该问用户什么)' }); + } + // ★ 这句话命中了 intent-map 里的诉求 ⇒ 判决应当把参数带出来 + const hitMap = IMAP.find((e) => e.utterance && c.utterance.includes(e.utterance) && e.params); + if (hitMap) { + expectParams++; + if (d.params) withParams++; + else carryProblems.push({ u: c.utterance, why: `命中 intent-map 的「${hitMap.utterance}」却没有带参数` }); + } + } + const carryOk = carryProblems.length === 0 + && withInputs === routeDecisions && withMustAsk === routeDecisions + && withParams === expectParams; + + return { + ok: acc1 >= MIN_TOP1 && acc3 >= MIN_TOP3 && hardGuesses.length === 0 && decisionOk && carryOk, + acc1, acc3, confRate, total: cases.length, misses, + top1, top3, confident, minTop1: MIN_TOP1, minTop3: MIN_TOP3, + // 拒答 + refusalTotal: (REFUSAL.cases || []).length, hardGuesses, + // 判决 + decided, decisionRight, refusedOnClear, decidedOnVague, decideProblems, decisionOk, + // 判决带出的东西 + routeDecisions, withInputs, withMustAsk, expectParams, withParams, carryProblems, carryOk, + }; +} + +/* ── CLI ── */ +const isMain = process.argv[1] + && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); +if (isMain) { + const r = runIntentCheck(); + console.log('=== 意图路由自检 ==='); + console.log(` 测试句 ${r.total} 句`); + console.log(` top-1 命中 ${r.top1}/${r.total} = ${(r.acc1 * 100).toFixed(1)}%(阈值 ${(r.minTop1 * 100).toFixed(0)}%)`); + console.log(` top-3 命中 ${r.top3}/${r.total} = ${(r.acc3 * 100).toFixed(1)}%(阈值 ${(r.minTop3 * 100).toFixed(0)}%)`); + console.log(` "有把握"比例 ${r.confident}/${r.total} = ${(r.confRate * 100).toFixed(1)}%`); + // ★ 拒答集 + console.log(`\n 拒答集 ${r.refusalTotal} 句:硬猜 ${r.hardGuesses.length} 句(必须 0)`); + if (r.hardGuesses.length) { + for (const g of r.hardGuesses) { + console.log(` · 「${g.u}」(${g.why}) ⇒ 硬猜成 ${g.got}${g.top ? ` 命中词:${g.top.join('、')}` : ''}`); + } + } + // ★ 判决模式 + console.log(` 判决模式:明确句 ${r.decided}/${r.total} 判决(其中对 ${r.decisionRight})` + + ` · 明确句被拒 ${r.refusedOnClear} · 模糊句被硬判 ${r.decidedOnVague}(两个都必须 0)`); + for (const p of r.decideProblems) { + console.log(` · 「${p.u}」 ⇒ ${p.why}${p.got ? ` 实际=${p.got}` : ''}${p.reason ? ` ${p.reason}` : ''}`); + } + // ★ 判决带出的"接下来要什么" + console.log(` 判决带出:路线判决 ${r.routeDecisions} 条(带出输入 ${r.withInputs} · 带出该问用户 ${r.withMustAsk})` + + ` · 命中诉求可查参数 ${r.expectParams}(带出 ${r.withParams})`); + for (const p of r.carryProblems) { + console.log(` · 「${p.u}」 ⇒ ${p.why}`); + } + if (r.misses.length) { + console.log('\n 未命中 top-1 的句子:'); + for (const m of r.misses) { + console.log(` · 「${m.u}」 期望 ${m.want},实际 ${m.got ?? '(无候选)'}${m.top ? ` 命中词:${m.top.join('、')}` : ''}`); + } + } + console.log(`\n=== ${r.ok ? 'PASS' : 'FAIL'} ===`); + if (!r.ok) { + console.log(`★ 准确率不达标(top-1 ${(r.acc1 * 100).toFixed(1)}% / top-3 ${(r.acc3 * 100).toFixed(1)}%)`); + console.log('★ 两条路:① 补同义词(intent-routes.json 的 keywords)② 调阈值(但别调到没意义)'); + } + process.exit(r.ok ? 0 : 1); +} diff --git a/plugins/antianqi/jianying/scripts/selftest-mcp.mjs b/plugins/antianqi/jianying/scripts/selftest-mcp.mjs new file mode 100644 index 00000000..e504d61d --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-mcp.mjs @@ -0,0 +1,902 @@ +// jianying MCP server 的客户端验收脚本 +// 用法: node selftest-mcp.mjs +// 环境: JYAI_HOME 指向 JYAI 仓库 +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SRV = path.join(HERE, '..', 'mcp', 'server.mjs'); +const JYAI = process.env.JYAI_HOME || path.join(process.env.USERPROFILE, 'JYAI'); +const SRC = path.join(JYAI, 'out', 'p47', 'full1080-with-audio.mp4'); +const OUT = path.join(JYAI, 'out', 'p67', 'mcp-test'); +// ★ 2026-10-21:不再用【别的跑】留下的帧目录当基线(out/p1-e2e-clean)。 +// 官方渲染器同态重渲染就有噪声(实测 17/108 帧变、最差 MAD 123.55), +// 拿它当"改动可见"的证据站不住脚 ⇒ 改成【本次自产】的合成帧,确定性可复跑。 +const FR_BASE = path.join(OUT, 'frames-base'); +const FR_NOISE = path.join(OUT, 'frames-noise'); +const FR_BIG = path.join(OUT, 'frames-big'); +const FR_CLIP = path.join(OUT, 'frames-clip'); + +function which(cmd) { + const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const d of (process.env.PATH || '').split(path.delimiter)) { + if (!d) continue; + for (const e of exts) { + const p = path.join(d, cmd + e); + try { if (fs.existsSync(p) && fs.statSync(p).isFile()) return p; } catch { /* ignore */ } + } + } + return cmd; +} +const FFMPEG = which('ffmpeg'); +const FFPROBE = (() => { + const d = path.dirname(which('ffmpeg')); + const p = path.join(d, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); + return fs.existsSync(p) ? p : 'ffprobe'; +})(); +// Python(烘 3D LUT 用;绝不写死盘符) +const PYTHON = (() => { + for (const c of ['python', 'python3']) { + try { + const p = which(c); + if (p) return p; + } catch { /* ignore */ } + } + return process.platform === 'win32' ? 'python' : 'python3'; +})(); +function sh(args) { + return new Promise((resolve) => { + const c = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let se = ''; + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => resolve({ code, stderr: se })); + }); +} +// 任意可执行文件版本(ffprobe 等)—— sh() 写死了 FFMPEG +// ★ 注意:ffprobe 的 -show_entries 结果走【stdout】;只有错误信息走 stderr +// (2026-10-22 自检抓到的假失败:只收 stderr ⇒ streams 解析成空字符串) +function shBin(bin, args) { + return new Promise((resolve) => { + const c = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => resolve({ code, stdout: so, stderr: se })); + }); +} +// 合成一组确定性的帧(纯色 + 亮度偏移)⇒ 噪声基线 / 真改动 / 新增削波 各一组 +// ★ 基色里放一个接近白的(F0F0F0)⇒ 用来复现"绝对值削波口径"那个坑(§76.5) +async function synthFrames(dir, brightness) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + const colors = ['F0F0F0', '606060', '808080']; + for (let i = 0; i < colors.length; i++) { + const f = path.join(dir, String(i).padStart(5, '0') + '.png'); + const args = ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=0x${colors[i]}:s=64x64`]; + if (brightness) args.push('-vf', `eq=brightness=${brightness}`); + args.push('-frames:v', '1', f); + const r = await sh(args); + if (r.code !== 0) return 0; + } + return colors.length; +} + +// ★ 逐帧稳定性用例(§82):造三组帧 +// base : 3 帧原样 +// unstable: 只有第 1 帧被大幅扰动(模拟"官方预览渲染在该帧非确定") +// changed : 只有第 2 帧被小幅真实改动 +// ⇒ 期望:第 1 帧被判为不稳定并排除;第 2 帧的稳定改动仍被检出 ⇒ 判"可见" +async function synthFramesPerFrame(dir, { unstableIndex = null, unstableBright = null, changedIndex = null, changedBright = null } = {}) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + const colors = ['606060', '808080', 'A0A0A0']; + for (let i = 0; i < colors.length; i++) { + let bright = null; + if (i === unstableIndex) bright = unstableBright; + if (i === changedIndex) bright = changedBright; + const f = path.join(dir, String(i).padStart(5, '0') + '.png'); + const args = ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=0x${colors[i]}:s=64x64`]; + if (bright !== null) args.push('-vf', `eq=brightness=${bright}`); + args.push('-frames:v', '1', f); + const r = await sh(args); + if (r.code !== 0) return 0; + } + return colors.length; +} + +const child = spawn(process.execPath, [SRV], { + env: { ...process.env, JYAI_HOME: JYAI, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }, + stdio: ['pipe', 'pipe', 'inherit'], +}); +let buf = ''; +const pending = new Map(); +child.stdout.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id !== undefined && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } + } +}); +let id = 0; +function call(method, params) { + const myId = ++id; + return new Promise((resolve) => { + pending.set(myId, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }) + '\n'); + }); +} + +let PASS = 0, FAIL = 0; +function check(name, ok, detail) { + if (ok) { PASS++; console.log(` [PASS] ${name}${detail ? ' — ' + detail : ''}`); } + else { FAIL++; console.log(` [FAIL] ${name}${detail ? ' — ' + detail : ''}`); } +} + +const textOf = (r) => (r.result?.content || []).map((c) => c.text || '').join('\n'); + +(async () => { + console.log('=== jianying MCP server 验收 ===\n'); + + // 1. 握手 + 工具清单 + const init = await call('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1' } }); + check('initialize 握手', init.result?.serverInfo?.name === 'jianying', `serverInfo=${JSON.stringify(init.result?.serverInfo)}`); + const list = await call('tools/list', {}); + const tools = list.result?.tools || []; + check('tools/list 返回 10 个动词', tools.length === 10, `实际 ${tools.length}:${tools.map((t) => t.name).join(', ')}`); + const noSchema = tools.filter((t) => !t.inputSchema || t.inputSchema.type !== 'object'); + + // ★★★ 安全:会话状态不得读/写 PLUGIN_DATA 之外(PR #72 review 的合并阻塞项) + // 9 种恶意 sessionId × 3 个动词 + 写路径检查 + 阳性对照(证明探针有区分力) + const secMod = await import('./security-probe.mjs'); + const sec = await secMod.runSecurityProbe(); + const secBad = sec.cases.filter((c) => !c.ok); + check('★★★ 会话状态越界探针(读/写 PLUGIN_DATA 之外全被拒)', sec.fail === 0, + secBad.length ? secBad.map((c) => c.name).join(' | ') : `${sec.pass} PASS / ${sec.fail} FAIL`); + check('每个动词都有 inputSchema', noSchema.length === 0, noSchema.map((t) => t.name).join(',') || '全部有'); + const ga = tools.find((t) => t.name === 'grade_apply'); + const nodeKeys = Object.keys(ga?.inputSchema?.properties?.spec?.properties || {}); + check('grade_apply 的 spec 枚举了节点', nodeKeys.length >= 10, `${nodeKeys.length} 个节点:${nodeKeys.join(',')}`); + + // 2. 入参 schema 硬校验:未知键必须被拒(防静默失效) + const bad = await call('tools/call', { name: 'grade_apply', arguments: { src: SRC, outDir: OUT, spec: { adjustColor: { brightness: 0.1 }, not_a_node: { x: 1 } } } }); + const badTxt = textOf(bad); + check('★ 非法 spec 键被拒绝', bad.result?.isError === true && /未知键|不合法/.test(badTxt), badTxt.split('\n').slice(0, 2).join(' / ')); + + const bad2 = await call('tools/call', { name: 'grade_apply', arguments: { src: SRC, outDir: OUT, spec: { adjustColor: { brightness: 5 } } } }); + check('★ 越界参数被拒绝', bad2.result?.isError === true && /最大|不合法/.test(textOf(bad2)), textOf(bad2).split('\n').slice(0, 2).join(' / ')); + + // 3. 正常调色:必须出片 + 探针全非零 + const g = await call('tools/call', { name: 'grade_apply', arguments: { + src: SRC, outDir: OUT, frames: 4, + spec: { adjustColor: { brightness: 0.12, contrast: 0.08 }, logWheel: { shadow: { r: 0.10, g: 0.04, b: 0, s: 0.05 } } }, + } }); + const gTxt = textOf(g); + const gOk = !g.result?.isError; + check('grade_apply 正常出片', gOk, gOk ? gTxt.replace(/\s+/g, ' ').slice(0, 150) : gTxt.slice(0, 200)); + if (gOk) { + const j = JSON.parse(gTxt); + check('grade_apply 内建探针:所有节点非零', Object.entries(j.probe).filter(([k]) => k !== '_full_chain').every(([, v]) => v > 0), JSON.stringify(j.probe)); + check('grade_apply 调色改变量 > 0', j.gradeChangeMad > 0, `MAD=${j.gradeChangeMad}`); + } + + // 4. verify_diff:本次自产的合成帧(不依赖别的跑的残留目录) + // base = 原帧;noise = 亮度 −0.01(模拟渲染噪声);big = 亮度 −0.06(模拟真改动) + const nBase = await synthFrames(FR_BASE, 0); + const nNoise = await synthFrames(FR_NOISE, -0.01); + const nBig = await synthFrames(FR_BIG, -0.06); + const nClip = await synthFrames(FR_CLIP, 0.2); // ★ 0xF0F0F0 @ +0.2 ⇒ 255 ⇒ 真削波(实测 +0.03 只到 244,不削) + check('合成帧目录就绪', nBase > 0 && nNoise > 0 && nBig > 0 && nClip > 0, + `base=${nBase} noise=${nNoise} big=${nBig} clip=${nClip}`); + + // 4a. 未校准:噪声也会被判成"可见"(这正是要暴露的问题) + const v0 = await call('tools/call', { name: 'verify_diff', arguments: { + beforeDir: FR_BASE, afterDir: FR_NOISE, checks: ['visibility'] } }); + const v0j = (() => { try { return JSON.parse(textOf(v0)); } catch { return null; } })(); + const vis0 = v0j?.checks?.visibility || {}; + check('verify_diff 未校准时会标注 calibrated=false', + vis0.calibrated === false && /未校准/.test(vis0.note || ''), + `maxMad=${vis0.maxMad} threshold=${vis0.threshold} note=${(vis0.note || '').slice(0, 40)}…`); + + // 4b. ★ 校准后:噪声 ⇒ 必须判"不可见";且【白底不算新增削波】(增量口径) + const v1 = await call('tools/call', { name: 'verify_diff', arguments: { + beforeDir: FR_BASE, afterDir: FR_NOISE, noiseDir: FR_NOISE, checks: ['visibility', 'picture'] } }); + const v1j = (() => { try { return JSON.parse(textOf(v1)); } catch { return null; } })(); + const vis = v1j?.checks?.visibility || {}; + check('★ verify_diff 校准后:噪声 ⇒ 判 FAIL(不可见)', + v1.result?.isError === true && v1j?.verdict === 'FAIL' && vis.calibrated === true, + `maxMad=${vis.maxMad} 噪声底=${vis.noiseMad} 判据线=${vis.noiseBar}`); + const pic = v1j?.checks?.picture || {}; + check('verify_diff 画质维度:白底不算【新增】削波(增量口径)', + Array.isArray(pic.meanDrift) && pic.meanDrift.length === 3 + && Math.abs(pic.clipDelta ?? 1) < 1e-6 && Math.abs(pic.underDelta ?? 1) < 1e-6 + && !(v1j?.problems || []).some((p) => /削波|死黑/.test(p)), + `clip=${pic.clipRatio} clipΔ=${pic.clipDelta} underΔ=${pic.underDelta} drift=${JSON.stringify(pic.meanDrift)}`); + + // 4c. ★★ 负向注入:真改动 ⇒ 必须判"可见"(证明这条判据不是恒 FAIL) + const v1b = await call('tools/call', { name: 'verify_diff', arguments: { + beforeDir: FR_BASE, afterDir: FR_BIG, noiseDir: FR_NOISE, checks: ['visibility'] } }); + const v1bj = (() => { try { return JSON.parse(textOf(v1b)); } catch { return null; } })(); + const visb = v1bj?.checks?.visibility || {}; + check('★★ verify_diff 负向注入:真改动 ⇒ 判 PASS(可见)', + v1b.result?.isError !== true && v1bj?.verdict === 'PASS' && visb.calibrated === true, + `maxMad=${visb.maxMad} 噪声底=${visb.noiseMad} 倍数=${visb.madOverNoise}`); + + // 4d. ★★ 负向注入:把白底【推上去】⇒ 必须报"新增削波"(证明增量口径不是恒 0) + const v1c = await call('tools/call', { name: 'verify_diff', arguments: { + beforeDir: FR_BASE, afterDir: FR_CLIP, noiseDir: FR_NOISE, checks: ['visibility', 'picture'] } }); + const v1cj = (() => { try { return JSON.parse(textOf(v1c)); } catch { return null; } })(); + const picc = v1cj?.checks?.picture || {}; + check('★★ verify_diff 负向注入:白底被推爆 ⇒ 必须报「新增削波」', + (picc.clipDelta ?? 0) > 0.02 && (v1cj?.problems || []).some((p) => /削波/.test(p)), + `clip=${picc.clipRatio} clipΔ=${picc.clipDelta} problems=${JSON.stringify(v1cj?.problems)}`); + + // 4e. ★★ 逐帧稳定性(§82):噪声集中在少数帧 ⇒ 不稳定帧排除,稳定帧的改动仍要检出 + const FR_PF_BASE = path.join(OUT, 'frames-pf-base'); + const FR_PF_UNSTABLE = path.join(OUT, 'frames-pf-unstable'); + const FR_PF_CHANGED = path.join(OUT, 'frames-pf-changed'); + await synthFramesPerFrame(FR_PF_BASE, {}); + await synthFramesPerFrame(FR_PF_UNSTABLE, { unstableIndex: 1, unstableBright: -0.5 }); + await synthFramesPerFrame(FR_PF_CHANGED, { changedIndex: 2, changedBright: -0.2 }); + const v1d = await call('tools/call', { name: 'verify_diff', arguments: { + beforeDir: FR_PF_BASE, afterDir: FR_PF_CHANGED, noiseDir: FR_PF_UNSTABLE, checks: ['visibility'] } }); + const v1dj = (() => { try { return JSON.parse(textOf(v1d)); } catch { return null; } })(); + const visd = v1dj?.checks?.visibility || {}; + check('★★ verify_diff 逐帧稳定性:不稳定帧被单独标出', + visd.unstableFrameCount === 1 && (visd.unstableFrames || []).includes(1), + `unstableFrames=${JSON.stringify(visd.unstableFrames)} visibleFrameCount=${visd.visibleFrameCount}`); + check('★★ verify_diff 逐帧稳定性:稳定帧上的真实改动仍被判为可见', + v1d.result?.isError !== true && v1dj?.verdict === 'PASS' && (visd.visibleMaxMad ?? 0) > 0, + `verdict=${v1dj?.verdict} visibleMaxMad=${visd.visibleMaxMad} @frame${visd.visibleFrame}`); + + // 5. ★ 阴性:同一目录对同一目录 ⇒ 必须 FAIL(判据要有判别力) + const v2 = await call('tools/call', { name: 'verify_diff', arguments: { beforeDir: FR_BASE, afterDir: FR_BASE } }); + check('★ verify_diff 对无变化必须 FAIL', v2.result?.isError === true, textOf(v2).replace(/\s+/g, ' ').slice(0, 140)); + + // 6. draft_open 护栏:allowReal=true 但 copy 仍为 true ⇒ 拒绝 + const d1 = await call('tools/call', { name: 'draft_open', arguments: { draft: 'x', copy: true, allowReal: true } }); + check('★ draft_open 护栏:allowReal 与 copy 冲突时拒绝', d1.result?.isError === true, textOf(d1).slice(0, 120)); + + // 7. ★ make_film:一条命令出成片(官方画面 + 我们的调色/混音 + ffmpeg 编码) + // 结构性断言 + 阴性断言永远跑;真 e2e 需要草稿库(env JYAI_DRAFT_LIB)+ 剪映在跑 + // ⇒ 没有草稿库时【显式 SKIP 且不计入 PASS】(不做假绿) + const FILM_LIB = process.env.JYAI_DRAFT_LIB || ''; + const FILM_DRAFT = process.env.JYAI_FILM_DRAFT || (FILM_LIB ? path.join(FILM_LIB, 'JYAI能力批测9a-纯视频探针') : ''); + const FILM_OUT = path.join(OUT, 'film'); + const mf = tools.find((t) => t.name === 'make_film'); + check('tools/list 暴露 make_film', !!mf, mf ? Object.keys(mf.inputSchema?.properties || {}).join(',') : '缺失'); + check('make_film schema 含 draft/outDir/lut/denoise/beautify', + !!mf && ['draft', 'outDir', 'lut', 'denoise', 'beautify'].every((k) => k in (mf.inputSchema?.properties || {})), + mf ? Object.keys(mf.inputSchema?.properties || {}).join(',') : ''); + + const mfBad = await call('tools/call', { name: 'make_film', arguments: { outDir: FILM_OUT } }); + check('★ make_film 缺必填参数被拒', mfBad.result?.isError === true, textOf(mfBad).replace(/\s+/g, ' ').slice(0, 120)); + + const mfNoDraft = await call('tools/call', { name: 'make_film', arguments: { draft: path.join(OUT, '__no_such_draft__'), outDir: FILM_OUT } }); + check('★ make_film 草稿不存在 ⇒ 报错(证明这个动词不是空壳)', + mfNoDraft.result?.isError === true, textOf(mfNoDraft).replace(/\s+/g, ' ').slice(0, 120)); + + // ★ 2026-10-23:剪映【刚启动】时 VE host 还没起来 ⇒ 第一次跑会误报一堆失败 + // (实测:刚起的剪映上 37 PASS / 3 FAIL,第二次跑就 43/0 ⇒ 是环境预热,不是代码) + // ⇒ 这里先等 VE 管道就绪(最多 ~30s),就绪才跑 e2e;等不到就整段 SKIP(不计入 PASS)。 + let veReady = false; + if (FILM_DRAFT && fs.existsSync(FILM_DRAFT)) { + for (let i = 0; i < 12; i++) { + const probe = await call('tools/call', { name: 'draft_open', arguments: { draft: FILM_DRAFT, copy: true } }); + if (probe.result?.isError !== true) { veReady = true; break; } + await new Promise((r) => setTimeout(r, 2500)); + } + if (!veReady) console.log(' ★ 剪映 VE 管道 30 秒内没就绪(剪映在跑吗?)'); + } + + if (FILM_DRAFT && fs.existsSync(FILM_DRAFT) && veReady) { + // e2e ①:双流草稿(素材齐全)⇒ 必须出"画面+音轨",且内建验收(响度/时长断言)判通过 + // fps 取 6(帧数减半、时长不变 ⇒ 音画对齐不受影响,只为省时间) + const mfRun = await call('tools/call', { name: 'make_film', arguments: { draft: FILM_DRAFT, outDir: FILM_OUT, fps: 6, denoise: 'auto' } }); + const mfTxt = textOf(mfRun); + const mfOk = mfRun.result?.isError !== true; + check('★★ make_film 真出片(官方渲染器画面 + 我们的混音链)', mfOk, + mfOk ? mfTxt.replace(/\s+/g, ' ').slice(0, 200) : mfTxt.replace(/\s+/g, ' ').slice(0, 200)); + if (mfOk) { + const film = path.join(FILM_OUT, 'film.mp4'); + const has = fs.existsSync(film); + check('make_film 成片文件落盘', has, film); + if (has) { + const st = await shBin(FFPROBE, ['-v', 'error', '-show_entries', 'stream=codec_type,codec_name,width,height,channels', + '-of', 'default=noprint_wrappers=1', film]); + const types = [...String(st.stdout || '').matchAll(/codec_type=(\w+)/g)].map((m) => m[1]); + // ★ 双流是【条件断言】:该草稿的音频素材文件可能不在磁盘上(环境状态)⇒ 那时只要求出片成功 + const audioMissing = /音频素材缺失\s*\d+\s*个/.test(mfTxt) || /无音轨 ⇒ 响度不判定/.test(mfTxt); + if (audioMissing) { + console.log(` [SKIP] make_film 双流断言 —— 该草稿的音频素材不在磁盘上(环境状态)⇒ 视频流已验: ${types.join(',')}`); + check('★ make_film 至少出了视频流', types.includes('video'), `streams=${types.join(',')}`); + } else { + check('★★ make_film 成片双流齐备(video+audio)', types.includes('video') && types.includes('audio'), + `streams=${types.join(',')} size=${fs.statSync(film).size}B`); + } + check('★★ make_film 内建验收判通过(响度/时长断言)', /验收通过/.test(mfTxt), + (mfTxt.match(/整体响度[^\n]*/) || ['(无响度行)'])[0].trim()); + } + } + // ★ e2e ②(无音轨):现在 e2e 草稿本身就是【纯视频、无可用音轨】的 + // (草稿库清理后,带音频的测试稿已不存在 ⇒ 9a-纯视频探针 顶上) + // ⇒ 断言:这种草稿也必须出片、且【不能】判失败(2026-10-22 抓到的真缺陷) + check('★★ make_film:草稿无可用音轨 ⇒ 仍出片(纯画面),不判失败', + mfOk && /验收通过/.test(mfTxt) && !/✘ 验收不通过/.test(mfTxt), + (mfTxt.match(/整体响度[^\n]*/) || ['(无响度行)'])[0].trim()); + // e2e ③:★★ 我们自己给曲线(grade.curves)⇒ ① 烘出的 cube 必须【不是恒等】 + // (判据不依赖渲染确定性:只查 cube)② 少一条通道必须【报错】(不许静默恒等) + const CURVE = [0, 0, 0.1, 0.0533, 0.2, 0.1067, 0.3, 0.16, 0.4067, 0.3933, 0.5133, 0.6267, + 0.62, 0.86, 0.7467, 0.9067, 0.8733, 0.9533, 1, 1, 1, 1, 1, 1]; + const four = { red: CURVE, green: CURVE, blue: CURVE, luma: CURVE }; + const gOut = path.join(OUT, 'film-grade'); + const gRun = await call('tools/call', { + name: 'make_film', + arguments: { draft: FILM_DRAFT, outDir: gOut, fps: 6, denoise: 'off', grade: { curves: four } }, + }); + const gTxt = textOf(gRun); + check('★★ make_film 接受 grade(我们自己给曲线)', gRun.result?.isError !== true, + gRun.result?.isError !== true ? 'ok' : gTxt.replace(/\s+/g, ' ').slice(0, 200)); + const cube = path.join(gOut, 'grade.cube'); + if (fs.existsSync(cube)) { + // cube 与 identity 的最大偏差 ⇒ > 0.01 才算真的烘出了曲线 + // (.cube 行序:R 最快 ⇒ index i ⇒ r=i%N, g=(i//N)%N, b=i//(N*N);恒等值=(r,g,b)/(N-1)) + const dev = await shBin(PYTHON, ['-c', [ + 'import sys', + 'p = sys.argv[1]', + 'N = 0', + 'rows = []', + 'for line in open(p, encoding="utf-8", errors="ignore"):', + ' if line.startswith("LUT_3D_SIZE"):', + ' N = int(line.split()[1]); continue', + ' if line.startswith("#") or line.startswith("DOMAIN"):', + ' continue', + ' t = line.split()', + ' if len(t) >= 3:', + ' rows.append((float(t[0]), float(t[1]), float(t[2])))', + 'm = 0.0', + 'den = max(N - 1, 1)', + 'for i, v in enumerate(rows):', + ' r = i % N; g = (i // N) % N; b = i // (N * N)', + ' m = max(m, abs(v[0]-r/den), abs(v[1]-g/den), abs(v[2]-b/den))', + 'print("%.6f" % m)', + ].join('\n'), cube]); + const dv = parseFloat(String(dev.stdout || '').trim()); + check('★★ grade 烘出的 cube 不是恒等(曲线真的被应用了)', Number.isFinite(dv) && dv > 0.01, + `maxDev(identity)=${dv}`); + } else { + check('★★ grade 烘出的 cube 不是恒等(曲线真的被应用了)', false, 'grade.cube 没产出'); + } + const badRun = await call('tools/call', { + name: 'make_film', + arguments: { draft: FILM_DRAFT, outDir: path.join(OUT, 'film-grade-bad'), fps: 6, denoise: 'off', + grade: { curves: { red: CURVE } } }, // ★ 只给一条通道 ⇒ 必须报错,不许静默恒等 + }); + check('★★ grade 少一条曲线通道 ⇒ 明确报错(不许静默烘成恒等)', + badRun.result?.isError === true, textOf(badRun).replace(/\s+/g, ' ').slice(-200)); + // ★ 渲染期算子(sharpen/vignetting/particle)3D LUT 表达不了 ⇒ 写进来必须报错(不许静默忽略) + const rnRun = await call('tools/call', { + name: 'make_film', + arguments: { draft: FILM_DRAFT, outDir: path.join(OUT, 'film-grade-rn'), fps: 6, denoise: 'off', + grade: { sharpen: { intensity: 0.6 } } }, + }); + check('★★ grade 含渲染期算子(sharpen) ⇒ 明确报错(指向 grade_apply),不许静默忽略', + rnRun.result?.isError === true && /grade_apply|渲染期/.test(textOf(rnRun)), + textOf(rnRun).replace(/\s+/g, ' ').slice(-200)); + + // ── ★★ 2026-10-22 审计:参数名拼错 ⇒ 过去【静默烘成恒等 LUT】───── + // 实测:make_film --grade '{"adjustColor":{"brightnessX":0.1}}' 产出的 cube + // 与【空 spec】的 cube 逐字节相同(md5 3abf7454…)⇒ 调色根本没发生,但出片"成功"、验收"通过"。 + // 根因:make-film.schema.json 里 grade 的每个节点只写了 {type:object}(弱校验), + // 而烘焙器也不认识拼错的键 ⇒ 两层都不拦。 + const typoRun = await call('tools/call', { + name: 'make_film', + arguments: { draft: FILM_DRAFT, outDir: path.join(OUT, 'film-grade-typo'), fps: 6, denoise: 'off', + grade: { adjustColor: { brightnessX: 0.1 } } }, + }); + check('★★ grade 参数名拼错 ⇒ 明确报错(不许静默烘成恒等 LUT)', + typoRun.result?.isError === true && /未知键|不合法/.test(textOf(typoRun)), + textOf(typoRun).replace(/\s+/g, ' ').slice(-200)); + // ★ 越界数值同样必须被拒(强度语义是 −1..1,界面 0..100 要换算) + const rangeRun = await call('tools/call', { + name: 'make_film', + arguments: { draft: FILM_DRAFT, outDir: path.join(OUT, 'film-grade-range'), fps: 6, denoise: 'off', + grade: { adjustColor: { brightness: 5 } } }, + }); + check('★★ grade 数值越界 ⇒ 明确报错(强度语义是 −1..1)', + rangeRun.result?.isError === true && /最大|不合法/.test(textOf(rangeRun)), + textOf(rangeRun).replace(/\s+/g, ' ').slice(-200)); + // ★★★ 绕过 schema 直接调烘焙器 ⇒ 同样必须报错(防线在【实现层】,不只靠 schema) + const bakeDirect = await shBin(PYTHON, ['-c', [ + 'import importlib.util, sys', + 'spec = importlib.util.spec_from_file_location("b", sys.argv[1])', + 'm = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(m)', + 'm.bake({"adjustColor": {"brightnessX": 0.1}})', + 'print("NO_ERROR_BAKED")', + ].join('\n'), path.join(JYAI, 'tools', 'jyai-gl-bake.py')]); + const bdTxt = `${bakeDirect.stdout || ''}\n${bakeDirect.stderr || ''}`; + check('★★★ 绕过 schema 直接调烘焙器:拼错参数名 ⇒ 同样报错(防线在实现层)', + !/NO_ERROR_BAKED/.test(bdTxt) && /不认识的参数/.test(bdTxt), + bdTxt.replace(/\s+/g, ' ').slice(-200)); + + // ── ★★ MCP 层全动词冒烟(2026-10-22 审计补:此前 5 个动词【从未走过 MCP 层】) + // draft_read / draft_patch / draft_commit / render_frames / audio_mix + const SMOKE = path.join(OUT, 'mcp-smoke'); + // ★ video.add 需要一个【真的能读的视频源】(传草稿 json 会得 video_source_unreadable) + const FILM_MEDIA = process.env.JYAI_TEST_MEDIA + || path.join(process.env.LOCALAPPDATA || '', 'Temp', 'jy-media-probe.mp4'); + if (!fs.existsSync(FILM_MEDIA)) { + // 造一个 1 秒的测试视频(不依赖用户素材) + const ff = path.join(path.dirname(FFMPEG), process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); + try { + execFileSync(ff, + ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=gray:s=320x180:r=12:d=1', + '-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono', '-shortest', + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', FILM_MEDIA], + { windowsHide: true, stdio: 'ignore' }); + } catch { /* 造不出来就跳过该用例 */ } + } + const op1 = await call('tools/call', { name: 'draft_open', arguments: { draft: FILM_DRAFT, copy: true } }); + const opTxt = textOf(op1); + const sid1 = (opTxt.match(/ve-session-\d+/) || [])[0] || ''; + check('★★ MCP: draft_open 走通并给出 sessionId', op1.result?.isError !== true && !!sid1, sid1 || opTxt.slice(0, 120)); + + const rd1 = await call('tools/call', { name: 'draft_read', arguments: { sessionId: sid1 } }); + const rdTxt = textOf(rd1); + check('★★ MCP: draft_read(sessionId) 读回草稿结构', + rd1.result?.isError !== true && /"tracks"|"segments"/.test(rdTxt), rdTxt.replace(/\s+/g, ' ').slice(0, 100)); + + const rd2 = await call('tools/call', { name: 'draft_read', arguments: { path: path.join(FILM_DRAFT, 'draft_content.json'), root: FILM_DRAFT } }); + check('★★ MCP: draft_read(path+root) 读回草稿结构', + rd2.result?.isError !== true && /"tracks"|"segments"/.test(textOf(rd2)), textOf(rd2).replace(/\s+/g, ' ').slice(0, 100)); + + // ★ 回归:scope 已从 schema 移除 ⇒ 传它必须被【拒】(不许静默接受) + const rdScope = await call('tools/call', { name: 'draft_read', arguments: { path: path.join(FILM_DRAFT, 'draft_content.json'), root: FILM_DRAFT, scope: 'segments' } }); + check('★★ MCP: draft_read 传 scope 被拒(后端不支持 ⇒ 别装成能用)', + rdScope.result?.isError === true && /未知键/.test(textOf(rdScope)), textOf(rdScope).replace(/\s+/g, ' ').slice(0, 120)); + + const pt1 = await call('tools/call', { + name: 'draft_patch', + arguments: { op: 'video.add', sessionId: sid1, params: { path: FILM_MEDIA, startTime: 0, sourceDuration: 1000000, trackIndex: 9 } }, + }); + check('★★ MCP: draft_patch(video.add) 被官方接受', + fs.existsSync(FILM_MEDIA) && pt1.result?.isError !== true && /accepted"?\s*:\s*true/.test(textOf(pt1)), + textOf(pt1).replace(/\s+/g, ' ').slice(0, 120)); + + const cm1 = await call('tools/call', { name: 'draft_commit', arguments: { sessionId: sid1, realDraft: FILM_DRAFT } }); + check('★★ MCP: draft_commit 落盘且真草稿未变', + cm1.result?.isError !== true && /"committed"\s*:\s*true/.test(textOf(cm1)) + && /"realDraftUnchanged"\s*:\s*true/.test(textOf(cm1)), textOf(cm1).replace(/\s+/g, ' ').slice(0, 140)); + + // ★★ 护栏负向注入(2026-10-24):真草稿在【会话期间】被改 ⇒ 必须拒绝落盘 + // ★ 原护栏只是"落盘前 vs 落盘后",抓不到这一种 ⇒ 我们会把用户的修改【覆盖】掉。 + // 用草稿库里的【临时副本】当假真草稿 ⇒ 绝不动用户自己的草稿(结束时删掉)。 + { + const tmpReal = path.join(FILM_LIB, 'JYAI自检-真草稿护栏-' + Date.now()); + try { + fs.cpSync(FILM_DRAFT, tmpReal, { recursive: true }); + const ropen = await call('tools/call', { name: 'draft_open', arguments: { draft: tmpReal, copy: true } }); + const rsid = (textOf(ropen).match(/ve-session-\d+/) || [])[0]; + const rread = await call('tools/call', { name: 'draft_read', arguments: { sessionId: rsid } }); + let rseg = null; + try { + const oj = JSON.parse(textOf(rread)); + const dd = oj?.draft || oj; + rseg = ((dd?.tracks || []).flatMap((tr) => tr.segments || [])[0] || {}).id || null; + } catch { /* 结构变了 ⇒ 跳过 */ } + if (!rseg) { + console.log(' [SKIP] 真草稿会话期被改 ⇒ 拒绝落盘 —— 没找到 segmentId(结构变了)⇒ 【不计入 PASS】'); + } else { + await call('tools/call', { + name: 'draft_patch', + arguments: { op: 'video.setVolume', sessionId: rsid, params: { segmentId: rseg, volume: 0.2 } }, + }); + // ★ 故意改"真草稿"(会话期间)⇒ 模拟另一个窗口/用户改了它 + fs.appendFileSync(path.join(tmpReal, 'draft_content.json'), ' '); + const rcommit = await call('tools/call', { + name: 'draft_commit', arguments: { sessionId: rsid, realDraft: tmpReal }, + }); + const rct = textOf(rcommit); + check('★★★ 护栏负向注入:真草稿在会话期间被改 ⇒ 必须拒绝落盘(否则会覆盖用户的修改)', + rcommit.result?.isError === true && /会话期间被改动过|sha 不一致/.test(rct), + rct.replace(/\s+/g, ' ').slice(0, 200)); + } + } catch (e) { + check('★★★ 护栏负向注入:真草稿在会话期间被改 ⇒ 必须拒绝落盘', false, String(e).slice(0, 160)); + } finally { + try { fs.rmSync(tmpReal, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + + const rf1 = await call('tools/call', { name: 'render_frames', arguments: { draft: FILM_DRAFT, outDir: SMOKE, fps: 6 } }); + const framesOk = fs.existsSync(path.join(SMOKE, 'frames')) && + fs.readdirSync(path.join(SMOKE, 'frames')).some(f => /^\d+\.png$/.test(f)); + check('★★ MCP: render_frames 产出帧序列', rf1.result?.isError !== true && framesOk, + textOf(rf1).replace(/\s+/g, ' ').slice(0, 120)); + + // ★★ draft_read 能读【加密】草稿(官方管道解密回退) + // ★ 用户自己的真草稿在磁盘上是【加密封装】⇒ 之前走磁盘根本读不出来 ⇒ 这一条是刚需 + const ENC_FIX = path.join(FILM_LIB, 'JYAI能力批测-加密草稿夹具', 'draft_content.json'); + if (fs.existsSync(ENC_FIX)) { + const encRead = await call('tools/call', { + name: 'draft_read', + arguments: { path: ENC_FIX, root: FILM_LIB }, + }); + const encTxt = textOf(encRead); + check('★★ MCP: draft_read 能读【加密】草稿(官方管道解密回退)', + encRead.result?.isError !== true && /"tracks"/.test(encTxt), + encTxt.replace(/\s+/g, ' ').slice(0, 140)); + } else { + console.log(' [SKIP] 加密草稿读取 —— 夹具不在草稿库(JYAI能力批测-加密草稿夹具)⇒ 【不计入 PASS】'); + } + + const am1 = await call('tools/call', { + name: 'audio_mix', + arguments: { draft: FILM_DRAFT, video: path.join(FILM_DRAFT, 'draft_content.json'), out: path.join(SMOKE, 'mix.mp4') }, + }); + const amTxt = textOf(am1); + // ★ 这个草稿没有可用音轨 ⇒ 预期是"明确报 nothing to mix"(输入问题),而不是别的错 + check('★★ MCP: audio_mix 在无音轨草稿上明确报输入问题(nothing to mix),不静默出坏片', + am1.result?.isError === true ? /nothing to mix/.test(amTxt) : fs.existsSync(path.join(SMOKE, 'mix.mp4')), + amTxt.replace(/\s+/g, ' ').slice(0, 140)); + + // ── ★★★ run_pipeline:编排器(一条命令走完一条路线)── + // 路线定义在 references/pipeline-profiles.json ⇒ 这里跑【grade-and-render】 + // ★ 编排器渲的是【剪映当前打开的草稿】⇒ 先 open 目标草稿 + const rpOpen = await call('tools/call', { name: 'draft_open', arguments: { draft: FILM_DRAFT, copy: true } }); + const rpSid = (textOf(rpOpen).match(/ve-session-\d+/) || [])[0]; + const rp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'grade-and-render', + draft: FILM_DRAFT, + outDir: path.join(SMOKE, 'pipeline'), + grade: { adjustColor: { temperature: 0.15 } }, + fps: 6, denoise: 'auto', + }, + }); + const rpTxt = textOf(rp); + check('★★★ run_pipeline:按路线走完(grade-and-render)⇒ 编排器判 PASS 且出片存在', + rp.result?.isError !== true && /"verdict"\s*:\s*"PASS"/.test(rpTxt) + && fs.existsSync(path.join(SMOKE, 'pipeline', 'film.mp4')), + rpTxt.replace(/\s+/g, ' ').slice(0, 200)); + // ★ 阴性:未知路线必须被拒(不许静默跑别的) + const rpBad = await call('tools/call', { + name: 'run_pipeline', arguments: { profile: 'no-such-profile', draft: FILM_DRAFT }, + }); + check('★★★ run_pipeline:未知路线 ⇒ 明确报错(不许静默跑别的路线)', + rpBad.result?.isError === true && /未知路线|不在允许集合/.test(textOf(rpBad)), + textOf(rpBad).replace(/\s+/g, ' ').slice(0, 140)); + + // ── ★★★ 第 9 条路线:曲线类调色并出片(grade-chain-then-render)── + // ★ grade_apply 是【唯一一条能做曲线/HSL/色轮的路】(make_film 的 --grade 只能做强度型) + // ★ 断言两件事:① 逐节点探针都非零(每个节点单独打开画面都变了 ⇒ 调色真的生效) + // ② capture 取到的产物路径真实存在(编排器能把"上一步的产物"传给下一步) + const grRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'grade-chain-then-render', draft: FILM_DRAFT, + outDir: path.join(SMOKE, 'pipeline-grade'), + grade: { + adjustColor: { brightness: 0.12, contrast: 0.08 }, + logWheel: { shadow: { r: 0.10, g: 0.04, b: 0, s: 0.05 } }, + }, + }, + }); + const grTxt = textOf(grRp); + const grObj = (() => { try { return JSON.parse(grTxt); } catch { return null; } })(); + const grStep = (grObj?.steps || []).find((s) => s.verb === 'grade_apply'); + const grProbe = grStep?.result?.probe || {}; + const grNodes = Object.entries(grProbe).filter(([k]) => k !== '_full_chain'); + check('★★★ run_pipeline:曲线类调色并出片(grade-chain-then-render)⇒ 逐节点探针非零 + 调色改变量 > 0 + 产物存在', + grObj?.verdict === 'PASS' && grNodes.length > 0 && grNodes.every(([, v]) => v > 0) + && grStep?.result?.gradeChangeMad > 0 + && !!grObj?.vars?.$graded && fs.existsSync(grObj.vars.$graded), + `节点=${grNodes.map(([k, v]) => `${k}:${(+v).toFixed(4)}`).join(',')}` + + ` 改变量=${grStep?.result?.gradeChangeMad?.toFixed?.(4)}` + + ` 产物=${String(grObj?.vars?.$graded || '(缺)').split('\\').pop()}`); + + // ── ★★★ 单版审片路线(2026-10-24 新增):"审一下这版" ── + // ★ 这条路线只读不改(审片不授权修改)⇒ 断言里也要体现 + // ★ 三条断言各挑一个方向:① 噪声底量出来了 ② 没判的维度【点名】了 ③ 真草稿没被动过 + const revRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'review-only', draft: FILM_DRAFT, + outDir: path.join(SMOKE, 'pipeline-review'), + }, + }); + const revTxt = textOf(revRp); + const revObj = (() => { try { return JSON.parse(revTxt); } catch { return null; } })(); + const revStep = (revObj?.steps || []).find((s) => s.verb === 'verify_diff'); + const revChecks = revStep?.result?.checks || {}; + const revUndet = (revStep?.result?.undetermined || []).map((u) => u.check); + // ★ 断言三件事:① 噪声底量出来了 ② 没判的维度【被点名】了 ③ 响度要么达标、要么明说"未判定" + // (纯画面片没有音轨 ⇒ integratedLufs 为 null ⇒ 这时必须落在 undetermined 里,不能装作"响度正常") + const lufs = revChecks.audio?.after?.integratedLufs; + const lufsOk = typeof lufs === 'number' ? Math.abs(lufs + 14) <= 0.5 : revUndet.includes('audio'); + check('★★★ run_pipeline:单版审片(review-only)⇒ 噪声底量出来 + 没判的维度被点名 + 响度要么达标要么点名', + revObj?.verdict === 'PASS' && revUndet.includes('visibility') + && revChecks.visibility && typeof revChecks.visibility.noiseMad === 'number' + && revChecks.audio && 'after' in revChecks.audio && lufsOk + && revChecks.timeline?.single === true + && !!revChecks.draft_integrity, + `未判定=[${revUndet.join(',')}]` + + ` 噪声底=${revChecks.visibility?.noiseMad} 响度=${lufs === undefined ? '未测' : lufs}` + + ` 段数=${revChecks.timeline?.after?.segments}`); + + // ── ★★★ 两版对照审片(2026-10-24 新增):"把改前改后对着看一遍" ── + // ★ 用两份夹具当改前/改后(纯视频 vs 带音轨)⇒ 两版【画面一样、音轨不同】 + // ⇒ 正期望:画面判"不可见"(证明判据不是恒真)+ 时间线段数差 1 + 响度两版都量到 + const AUDIO_DRAFT = FILM_LIB ? path.join(FILM_LIB, 'JYAI能力批测-带音轨夹具') : ''; + if (!AUDIO_DRAFT || !fs.existsSync(AUDIO_DRAFT)) { + check('★★★ run_pipeline:两版对照审片(review-compare)⇒ 需要第二份草稿', + false, `没找到第二份草稿(${AUDIO_DRAFT || '(没有 JYAI_DRAFT_LIB)'})`); + } else { + const cmpRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'review-compare', draft: FILM_DRAFT, draftAfter: AUDIO_DRAFT, + outDir: path.join(SMOKE, 'pipeline-compare'), + }, + }); + const cmpTxt = textOf(cmpRp); + const cmpObj = (() => { try { return JSON.parse(cmpTxt); } catch { return null; } })(); + const cmpStep = (cmpObj?.steps || []).find((s) => s.verb === 'verify_diff'); + const cc = cmpStep?.result?.checks || {}; + // ★ 断言:① 跑完了(verify_diff 步骤执行了)② 画面判"不可见"(两版画面确实一样 ⇒ 判据有区分力) + // ③ 时间线段数差 = 1(两版结构不同 ⇒ 逐段对照生效)④ 两版响度都量到了 + check('★★★ run_pipeline:两版对照审片(review-compare)⇒ 画面判"不可见"(判据有区分力)+ 段数差 1 + 响度两版都测到', + !!cmpStep && cc.visibility && /不可见/.test(cc.visibility.verdict || '') + && cc.timeline?.segmentsDelta === 1 + && typeof cc.audio?.before?.integratedLufs === 'number' + && typeof cc.audio?.after?.integratedLufs === 'number', + `画面=${cc.visibility?.verdict} 噪声底=${cc.visibility?.noiseMad}` + + ` 段数Δ=${cc.timeline?.segmentsDelta}` + + ` 响度 ${cc.audio?.before?.integratedLufs}→${cc.audio?.after?.integratedLufs}` + + ` problems=${JSON.stringify(cmpStep?.result?.problems || []).slice(0, 80)}`); + } + + // ★ 缺参预检:一次点名【全部】缺参,并标出哪些必须问用户(别跑到一半才说缺) + const needRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { profile: 'talk-cut-then-render', draft: FILM_DRAFT }, // ★ 故意不给 srt / outDir + }); + const needTxt = textOf(needRp); + check('★★★ run_pipeline:缺参 ⇒ 一次性点名全部缺参,并标出"必须由用户提供"的', + needRp.result?.isError === true && /\$srt/.test(needTxt) && /\$outDir/.test(needTxt) + && /必须由用户提供/.test(needTxt), + needTxt.replace(/\s+/g, ' ').slice(0, 200)); + + // ── ★★★ 第二条【脚本步骤】路线:加字幕并出片(2026-10-24 新增)── + // ★ 价值有两个:① 字幕是高频真实需求 ② 这是【第二个脚本步骤】⇒ 证明脚本步骤机制不是 + // 只为 talk-cut 打的补丁;③ 字幕脚本自己会按 ±1 帧核对时码 ⇒ 它自证"字幕真的落进去了" + const SUB_SRT = path.join(OUT, 'sub.srt'); + fs.writeFileSync(SUB_SRT, + '1\n00:00:00,200 --> 00:00:00,900\n今天我们讲剪映插件\n\n' + + '2\n00:00:01,200 --> 00:00:02,000\n编排器怎么等人确认\n', 'utf8'); + const subRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'subtitle-then-render', draft: FILM_DRAFT, srt: SUB_SRT, + outDir: path.join(SMOKE, 'pipeline-sub'), + }, + }); + const subTxt = textOf(subRp); + check('★★★ run_pipeline:加字幕并出片(subtitle-then-render)⇒ PASS + 字幕时码吻合 + 成片存在', + subRp.result?.isError !== true && /"verdict"\s*:\s*"PASS"/.test(subTxt) + && /时码吻合\s*2\/2/.test(subTxt) + && fs.existsSync(path.join(SMOKE, 'pipeline-sub', 'film.mp4')), + subTxt.replace(/\s+/g, ' ').slice(-220)); + + // ── ★★★ 编排器的【产物传递】:出片 → 按草稿重做混音(2026-10-24 新增)── + // ★ 测的是 produces 机制:上一步的成片路径要能喂给下一步(否则"混音"这步拿不到画面) + // ★ 环境决定断言:草稿【没有音轨】时,混音必然失败 ⇒ 这时断言【诚实失败】(不许出片成功) + // ;给了 JYAI_FILM_DRAFT_AUDIO(有音轨的草稿)才断言成功路径。 + const MIX_LIB = process.env.JYAI_DRAFT_LIB || ''; + const MIX_DRAFT = process.env.JYAI_FILM_DRAFT_AUDIO + || (MIX_LIB ? path.join(MIX_LIB, 'JYAI能力批测-带音轨夹具') : ''); + const mixRp = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'render-and-mix', draft: MIX_DRAFT, + outDir: path.join(SMOKE, 'pipeline-mix'), + }, + }); + const mixTxt = textOf(mixRp); + const mixHasFilm = fs.existsSync(path.join(SMOKE, 'pipeline-mix', 'film.mp4')); + const mixHasMixed = fs.existsSync(path.join(SMOKE, 'pipeline-mix', 'mixed.mp4')); + if (MIX_DRAFT && fs.existsSync(MIX_DRAFT) && mixHasFilm && mixHasMixed) { + check('★★★ run_pipeline:出片并重做混音(render-and-mix)⇒ PASS + 混音成片存在', + mixRp.result?.isError !== true && /"verdict"\s*:\s*"PASS"/.test(mixTxt), + mixTxt.replace(/\s+/g, ' ').slice(-200)); + } else { + // ★ 阴性:草稿没有音轨 ⇒ 官方回 "nothing to mix" ⇒ 路线必须【判失败并说清原因】 + // (绝不能"没有音轨也照样出片成功"—— 那是不装成功) + check('★★★ run_pipeline:草稿没有音轨 ⇒ 混音路线必须明确失败(不装成功)', + /nothing to mix|"verdict"\s*:\s*"FAIL"/.test(mixTxt), + mixTxt.replace(/\s+/g, ' ').slice(-200)); + } + + // ── ★★★ run_pipeline 的【人工确认点 + 两段式续跑】(2026-10-24 新增)── + // 路线 talk-cut-then-render 里有一步带 confirm:true ⇒ 跑完那一步必须【停下来等人】, + // 后面几步一步都不跑;确认后用 resume{token, confirm:true} 续跑。 + // ★ 三个断言各挑一个方向:① 停下来(AWAIT_CONFIRM)② 没确认就不许跑(REFUSED)③ 确认后才跑完(PASS) + // ★ 阴性对照:REFUSED 那一次必须【证明】后面的脚本步骤没被执行(不是"看起来没跑") + const GATE_SRT = path.join(OUT, 'gate.srt'); + fs.writeFileSync(GATE_SRT, + '1\n00:00:00,200 --> 00:00:00,900\n嗯,那个,就是\n\n' + + '2\n00:00:01,000 --> 00:00:02,000\n今天我们讲剪映插件的编排器\n\n' + + '3\n00:00:02,200 --> 00:00:02,600\n嗯\n', 'utf8'); + const g1 = await call('tools/call', { + name: 'run_pipeline', + arguments: { + profile: 'talk-cut-then-render', draft: FILM_DRAFT, srt: GATE_SRT, + outDir: path.join(SMOKE, 'pipeline-gate'), + }, + }); + const g1Txt = textOf(g1); + let g1Obj = null; + try { g1Obj = JSON.parse(g1Txt); } catch { /* 不是 JSON */ } + const gateOk = g1Obj && g1Obj.verdict === 'AWAIT_CONFIRM' && g1Obj.gate + && g1Obj.gate.artifact && fs.existsSync(g1Obj.gate.artifact) + && g1Obj.gate.artifactSha && g1Obj.gate.pendingSteps > 0; + check('★★★ run_pipeline:带确认点的路线 ⇒ 停在关卡(AWAIT_CONFIRM)+ 产物已生成', + !!gateOk, + gateOk ? `第 ${g1Obj.gate.step} 步 · 产物 ${path.basename(g1Obj.gate.artifact)} · 还有 ${g1Obj.gate.pendingSteps} 步没跑` + : g1Txt.replace(/\s+/g, ' ').slice(0, 200)); + const gToken = g1Obj?.resume?.token || ''; + // ★ 阴性①:不确认 ⇒ 必须拒绝,而且【关卡后面的步骤一步都没跑】 + const g2 = await call('tools/call', { + name: 'run_pipeline', arguments: { profile: 'talk-cut-then-render', resume: { token: gToken } }, + }); + const g2Obj = (() => { try { return JSON.parse(textOf(g2)); } catch { return null; } })(); + const g2Steps = (g2Obj?.steps || []).map((s) => s.script || s.verb).filter(Boolean); + check('★★★ run_pipeline:没 confirm ⇒ 拒绝执行(REFUSED),且关卡后的脚本一步都没跑', + g2Obj?.verdict === 'REFUSED' && !g2Steps.includes('talk-cut-apply.mjs'), + g2Obj ? `verdict=${g2Obj.verdict} · 已跑步骤=[${g2Steps.join(',')}]` : textOf(g2).slice(0, 160)); + // ★ 阴性②:token 不存在 ⇒ 必须明确报错(不许默默从头跑) + const BAD_TOKEN = '0'.repeat(16); // 形态合法但状态里没有 ⇒ 只能报"找不到" + const gBad = await call('tools/call', { + name: 'run_pipeline', arguments: { profile: 'talk-cut-then-render', resume: { token: BAD_TOKEN, confirm: true } }, + }); + check('★★★ run_pipeline:不存在的 token ⇒ 明确报错(不许默默从头跑)', + gBad.result?.isError === true && /找不到流水线状态|token/.test(textOf(gBad)), + textOf(gBad).replace(/\s+/g, ' ').slice(0, 160)); + // ★ 阳性:确认后 ⇒ 续跑到底(apply 步骤必须【自证】剪掉了 2/2 个区间) + const g3 = await call('tools/call', { + name: 'run_pipeline', arguments: { profile: 'talk-cut-then-render', resume: { token: gToken, confirm: true } }, + }); + const g3Obj = (() => { try { return JSON.parse(textOf(g3)); } catch { return null; } })(); + const g3Txt = textOf(g3); + const g3Ok = g3Obj && g3Obj.verdict === 'PASS' + && g3Obj.steps.some((s) => s.script === 'talk-cut-apply.mjs' && s.ok === true) + && /已验证剪掉\s*2\/2/.test(g3Txt); + check('★★★ run_pipeline:确认后续跑 ⇒ 走完(PASS),且 apply 自证"2/2 个区间已剪掉"', + !!g3Ok, + g3Ok ? 'verdict=PASS · apply 自证 2/2' : g3Txt.replace(/\s+/g, ' ').slice(-260)); + + // ── ★★★ 覆盖度补强(2026-10-23):verify_diff 的【语义判据】路径 ── + // beforeDraft / afterDraft / expect —— 这是 jianying-draft-edit 文档教的主工作流第⑤步, + // 但【从未被任何测试触及】("未测参数清单"查出来的)⇒ 文档教的流程没人验证过。 + // 做法:开副本 → 读改前的明文 → patch 改一处音量 → 读改后的明文 → verify_diff 断言字段变了。 + let semParsed = null; + try { + const semOpen = await call('tools/call', { name: 'draft_open', arguments: { draft: FILM_DRAFT, copy: true } }); + const semSid = (textOf(semOpen).match(/ve-session-\d+/) || [])[0]; + const beforeTxt = textOf(await call('tools/call', { name: 'draft_read', arguments: { sessionId: semSid } })); + const beforeObj = JSON.parse(beforeTxt); + // ★ draft_read 返回的是【{draft:{...}} 包装】⇒ 先拆包装再找 tracks + const d0 = beforeObj?.draft || beforeObj || {}; + const segs = (d0.tracks || []).flatMap((t) => t.segments || []); + const seg = segs[0] || (d0.segments || [])[0] || null; + const segId = seg?.id || seg?.segment_id || seg?.segmentId || null; + if (!segId) { + console.log(' [SKIP] verify_diff 语义判据 —— 草稿里没找到 segmentId(结构变了)⇒ 【不计入 PASS】'); + } else { + // ★ verify_diff 的 readDraft 取的是【顶层 tracks】⇒ 存裸草稿,不是 {draft:{...}} 包装 + semParsed = { semSid, segId, beforeJson: JSON.stringify(d0, null, 2) }; + } + } catch (e) { + console.log(` [SKIP] verify_diff 语义判据 —— 解析草稿失败(${String(e).slice(0, 90)})⇒ 【不计入 PASS】`); + } + if (semParsed) { + const { semSid, segId, beforeJson } = semParsed; + const beforeFile = path.join(SMOKE, 'before-draft.json'); + const afterFile = path.join(SMOKE, 'after-draft.json'); + // ★★ 关键(2026-10-24 审计修正):先做【第一次】patch 把基线变成【运行时展开形态】 + // —— 剪映自己存的草稿是【精简形态】(段 11 键、没有 volume), + // 官方返回的是【展开形态】(段 52 键、有 volume) + // ⇒ 只做一次 patch 时,"改前(精简) vs 改后(展开)" 的 diff 里混着几十个形态字段 + // ⇒ 旧版判据会把形态噪音当成"volume 变了" ⇒ 【假绿】 + // ⇒ 现在连做两次 patch:before/after 都是展开形态 ⇒ 判定才精确 + const patch1 = await call('tools/call', { + name: 'draft_patch', + arguments: { op: 'video.setVolume', sessionId: semSid, params: { segmentId: segId, volume: 0.25 } }, + }); + const beforeExpandedTxt = textOf(await call('tools/call', { name: 'draft_read', arguments: { sessionId: semSid } })); + let beforeExpandedJson = beforeExpandedTxt; + try { + const bo = JSON.parse(beforeExpandedTxt); + beforeExpandedJson = JSON.stringify(bo?.draft || bo || {}, null, 2); + } catch { /* ignore */ } + fs.writeFileSync(beforeFile, beforeExpandedJson, 'utf8'); + const patchRes = await call('tools/call', { + name: 'draft_patch', + arguments: { op: 'video.setVolume', sessionId: semSid, params: { segmentId: segId, volume: 0.5 } }, + }); + const afterTxt = textOf(await call('tools/call', { name: 'draft_read', arguments: { sessionId: semSid } })); + let afterJson = afterTxt; + try { + const afterObj = JSON.parse(afterTxt); + afterJson = JSON.stringify(afterObj?.draft || afterObj || {}, null, 2); + } catch { /* 保持原样,让 verify_diff 去报"读不出来" */ } + fs.writeFileSync(afterFile, afterJson, 'utf8'); + const sem = await call('tools/call', { + name: 'verify_diff', + arguments: { + checks: ['timeline'], + beforeDraft: beforeFile, afterDraft: afterFile, + expect: { segmentFieldChanged: 'volume' }, + }, + }); + const semTxt = textOf(sem); + check('★★★ MCP: verify_diff 语义判据(beforeDraft/afterDraft/expect)⇒ 判得"volume 变了"', + patch1.result?.isError !== true && patchRes.result?.isError !== true + && sem.result?.isError !== true && /"verdict"\s*:\s*"PASS"/.test(semTxt), + semTxt.replace(/\s+/g, ' ').slice(0, 220)); + // ★ 阴性对照:两份草稿【相同】⇒ 必须判 FAIL(否则这条判据没有区分力) + const neg = await call('tools/call', { + name: 'verify_diff', + arguments: { + checks: ['timeline'], + beforeDraft: beforeFile, afterDraft: beforeFile, + expect: { segmentFieldChanged: 'volume' }, + }, + }); + const negTxt = textOf(neg); + check('★★★ MCP: verify_diff 语义判据的阴性对照(没改动 ⇒ 必须判 FAIL)', + neg.result?.isError === true && /"verdict"\s*:\s*"FAIL"/.test(negTxt), + negTxt.replace(/\s+/g, ' ').slice(0, 200)); + } + } else { + console.log(' [SKIP] 真 e2e(MCP 层冒烟 + make_film 出片)—— 需要 env JYAI_DRAFT_LIB(草稿库)' + + '+ 剪映在跑且 VE 管道就绪;未满足 ⇒ 【不计入 PASS】'); + } + + // ★★★ 意图路由:在一批带标注的真实用户句上量准确率(top-1/top-3) + // ★ 低于阈值直接判失败 ⇒ 路由表退化会立刻被发现(不是"看起来能用") + { + const intentMod = await import('./selftest-intent.mjs'); + const ir = intentMod.runIntentCheck(); + check('★★★ 意图路由准确率(带标注测试集:top-1 ≥ 80% · top-3 ≥ 90%)+ 拒答集不硬猜 + 判决模式', + ir.ok, + `top-1 ${(ir.acc1 * 100).toFixed(1)}% · top-3 ${(ir.acc3 * 100).toFixed(1)}%` + + ` · 拒答集 ${ir.refusalTotal} 句硬猜 ${ir.hardGuesses.length}(须 0)` + + ` · 判决 ${ir.decided}/${ir.total}(对 ${ir.decisionRight})明确句被拒 ${ir.refusedOnClear} 模糊句被硬判 ${ir.decidedOnVague}` + + (ir.misses.length ? ` · 未命中:${ir.misses.map((m) => m.u).join('、')}` : '') + + (ir.hardGuesses.length ? ` · 硬猜:${ir.hardGuesses.map((g) => g.u).join('、')}` : '')); + } + + // ★★★ 参数覆盖(schema 声明了、但此前【从未被端到端验证过】的那批参数) + // 覆盖:grade_apply.noLut · verify_diff.noiseK/threshold/beforeVideo/afterVideo + // make_film.lut · make_film.maxFrames + if (FILM_LIB) { + const pmod = await import('./selftest-params.mjs'); + const pr = await pmod.runParamsCheck(); + const prBad = pr.cases.filter((c) => !c.ok); + check('★★★ 参数覆盖(7 个从未验证过的参数 + 阳性前置)', pr.fail === 0, + prBad.length ? prBad.map((c) => c.name).join(' | ') : `${pr.pass} PASS / ${pr.fail} FAIL`); + } else { + console.log(' [SKIP] 参数覆盖 —— 需要 env JYAI_DRAFT_LIB(草稿库)⇒ 【不计入 PASS】'); + } + + console.log(`\n=== ${PASS} PASS / ${FAIL} FAIL ===`); + child.kill(); + process.exit(FAIL === 0 ? 0 : 1); +})(); diff --git a/plugins/antianqi/jianying/scripts/selftest-params.mjs b/plugins/antianqi/jianying/scripts/selftest-params.mjs new file mode 100644 index 00000000..f844a3de --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-params.mjs @@ -0,0 +1,261 @@ +// ★ 参数覆盖自检:补上"schema 声明了但从未被端到端验证过"的那批参数 +// +// 覆盖:grade_apply.noLut · verify_diff.threshold · verify_diff.noiseK +// make_film.lut · make_film.maxFrames +// ★ 每条判据都必须是【确定性】的:产物在不在、判据线变没变、帧数够不够 +// (不依赖"画面变没变"那种有噪声底的判断) +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SRV = path.join(HERE, '..', 'mcp', 'server.mjs'); +const WORK = path.join(os.tmpdir(), 'jianying-params-' + Date.now()); +// ★ 优先用【带音轨夹具】:只有它有音轨 ⇒ 响度/beautify 才有得验 +const AUDIO_FIXTURE = process.env.JYAI_DRAFT_LIB + ? path.join(process.env.JYAI_DRAFT_LIB, 'JYAI能力批测-带音轨夹具') : ''; +const PLAIN_FIXTURE = process.env.JYAI_DRAFT_LIB + ? path.join(process.env.JYAI_DRAFT_LIB, 'JYAI能力批测9a-纯视频探针') : ''; +const DRAFT = process.env.JYAI_FILM_DRAFT + || (fs.existsSync(AUDIO_FIXTURE) ? AUDIO_FIXTURE : PLAIN_FIXTURE); +const HAS_AUDIO = fs.existsSync(AUDIO_FIXTURE) + && fs.existsSync(path.join(AUDIO_FIXTURE, 'draft_content.json')); + +function startServer() { + const child = spawn(process.execPath, [SRV], { stdio: ['pipe', 'pipe', 'pipe'] }); + let buf = ''; + const pending = new Map(); + child.stdout.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id !== undefined && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } + } + }); + let id = 0; + const rpc = (method, params) => new Promise((resolve) => { + const myId = ++id; + pending.set(myId, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }) + '\n'); + }); + return { child, rpc }; +} +const textOf = (r) => (r.result?.content || []).map((c) => c.text || '').join('\n'); + +function ffmpeg() { + for (const d of (process.env.PATH || '').split(path.delimiter)) { + for (const e of ['', '.EXE', '.exe']) { + const p = path.join(d, 'ffmpeg' + e); + if (fs.existsSync(p)) return p; + } + } + if (process.env.ProgramFiles) { + const p = path.join(process.env.ProgramFiles, 'ffmpeg', 'ffmpeg.exe'); + if (fs.existsSync(p)) return p; + } + return 'ffmpeg'; +} +function run(bin, args) { + return new Promise((resolve) => { + const c = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => resolve({ code, stdout: so, stderr: se })); + }); +} + +export async function runParamsCheck() { + const cases = []; + const rec = (name, ok, detail) => cases.push({ name, ok: !!ok, detail: String(detail || '') }); + fs.mkdirSync(WORK, { recursive: true }); + + const { child, rpc } = startServer(); + await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'param-probe', version: '1' } }); + + // 造素材:① 1 秒灰视频(给 grade_apply)② 两张不同亮度的 png(给 verify_diff 的帧) + const VIDEO = path.join(WORK, 'gray.mp4'); + await run(ffmpeg(), ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=gray:s=320x180:r=12:d=1', + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', VIDEO]); + const framesFor = async (dir, color) => { + fs.mkdirSync(dir, { recursive: true }); + const f = path.join(dir, '00000.png'); + await run(ffmpeg(), ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=${color}:s=64x64`, '-frames:v', '1', f]); + return dir; + }; + + /* ── ① grade_apply.noLut:只出逐点直算版,不出 LUT 版 ── */ + { + const outDir = path.join(WORK, 'ga-nolut'); + const r = await rpc('tools/call', { + name: 'grade_apply', + arguments: { src: VIDEO, outDir, frames: 4, noLut: true, spec: { adjustColor: { brightness: 0.2 } } }, + }); + const has10 = fs.existsSync(path.join(outDir, 'graded10.mp4')); + const hasLut = fs.existsSync(path.join(outDir, 'graded_lut.mp4')); + rec('grade_apply.noLut ⇒ 只出逐点直算版(graded10.mp4),不出 LUT 版', + r.result?.isError !== true && has10 && !hasLut, + `isError=${r.result?.isError === true} graded10=${has10} graded_lut=${hasLut} :: ${textOf(r).replace(/\s+/g, ' ').slice(0, 120)}`); + } + + /* ── ② verify_diff.noiseK:判据线必须跟着 noiseK 变 ── */ + { + const A = await framesFor(path.join(WORK, 'fr-a'), '0x606060'); + const B = await framesFor(path.join(WORK, 'fr-b'), '0x707070'); + // ★ noiseK 只在【给了噪声底目录】时参与判据线(bar = calibrated ? max(th, noiseMad*noiseK) : th) + // ⇒ 必须同时给 noiseDir + threshold=0,否则判据线恒等于 th ⇒ 测不出 noiseK + const C = await framesFor(path.join(WORK, 'fr-c'), '0x616161'); + const k1 = await rpc('tools/call', { name: 'verify_diff', arguments: { beforeDir: A, afterDir: B, noiseDir: C, threshold: 0, noiseK: 1 } }); + const k3 = await rpc('tools/call', { name: 'verify_diff', arguments: { beforeDir: A, afterDir: B, noiseDir: C, threshold: 0, noiseK: 3 } }); + // ★ 判据线是【noiseBar】(不是 threshold —— 那个只是我传进去的阈值) + const bar = (t) => { + const m = /"noiseBar"\s*:\s*([0-9.]+)/.exec(textOf(t)); + return m ? Number(m[1]) : null; + }; + // ★ 阳性前置:噪声底必须真的被算出来(calibrated=true 且 noiseMad>0) + // 否则判据线不变可能只是"噪声底没生效",那样就测不出 noiseK + const calibrated = /"calibrated"\s*:\s*true/.test(textOf(k1)); + const noiseMad = (/"noiseMad"\s*:\s*([0-9.]+)/.exec(textOf(k1)) || [])[1]; + rec('verify_diff 噪声底被算出来(calibrated 且 noiseMad>0)⇒ 前提成立', + calibrated && Number(noiseMad) > 0, `calibrated=${calibrated} noiseMad=${noiseMad}`); + const t1 = bar(k1), t3 = bar(k3); + rec('verify_diff.noiseK ⇒ 判据线随 noiseK 变化(给了噪声底才生效)', + t1 !== null && t3 !== null && t3 !== t1, `noiseK=1 ⇒ 判据线 ${t1};noiseK=3 ⇒ 判据线 ${t3}`); + } + + /* ── ③ verify_diff.threshold:显式给的判据线要被采用 ── */ + { + const A = await framesFor(path.join(WORK, 'fr-a'), '0x606060'); + const B = await framesFor(path.join(WORK, 'fr-b'), '0x707070'); + const r = await rpc('tools/call', { + name: 'verify_diff', arguments: { beforeDir: A, afterDir: B, threshold: 42 }, + }); + const m = /"threshold"\s*:\s*([0-9.]+)/.exec(textOf(r)); + rec('verify_diff.threshold ⇒ 显式给的判据线被采用', + m && Number(m[1]) === 42, `返回的判据线=${m ? m[1] : '(没读到)'}`); + } + + /* ── ④ make_film.lut:外部 3D LUT 能被吃进去 ── */ + if (DRAFT && fs.existsSync(DRAFT)) { + // ★★ 必须先让【官方打开】这个草稿 —— make_film 的画面来自官方渲染器, + // 它渲染的是"剪映当前打开的草稿"。不打开 ⇒ 渲的是上一个草稿(实测:命中段数对不上)。 + await rpc('tools/call', { name: 'draft_open', arguments: { draft: DRAFT, copy: true } }); + const out1 = path.join(WORK, 'mf-lut-src'); + const r1 = await rpc('tools/call', { + name: 'make_film', + arguments: { draft: DRAFT, outDir: out1, fps: 6, denoise: 'off', grade: { adjustColor: { temperature: 0.15 } } }, + }); + const cube = path.join(out1, 'grade.cube'); + const ok1 = r1.result?.isError !== true && fs.existsSync(cube); + if (!ok1) { + rec('make_film.lut ⇒ 外部 3D LUT 能被吃进去', false, + `第一步(带 grade 产出 cube)失败:${textOf(r1).replace(/\s+/g, ' ').slice(0, 140)}`); + } else { + const out2 = path.join(WORK, 'mf-lut'); + const r2 = await rpc('tools/call', { + name: 'make_film', + arguments: { draft: DRAFT, outDir: out2, fps: 6, denoise: 'off', lut: cube }, + }); + const film = path.join(out2, 'film.mp4'); + rec('make_film.lut ⇒ 外部 3D LUT 能被吃进去(带 grade 的产出当外部 LUT 再走一遍)', + r2.result?.isError !== true && fs.existsSync(film), + `isError=${r2.result?.isError === true} film=${fs.existsSync(film)} :: ${textOf(r2).replace(/\s+/g, ' ').slice(0, 120)}`); + } + + /* ── ⑤ make_film.maxFrames:截帧必须真的截(画面帧数 ≤ maxFrames)── */ + { + const out = path.join(WORK, 'mf-maxframes'); + const r = await rpc('tools/call', { + name: 'make_film', + arguments: { draft: DRAFT, outDir: out, fps: 6, denoise: 'off', maxFrames: 6 }, + }); + let nFrames = -1; + try { + const info = await run(ffmpeg(), ['-v', 'error', '-i', path.join(out, 'film.mp4'), '-vf', 'select=gte(n\\,0)', '-vsync', '0', '-f', 'null', '-']); + const m = /frame=\s*(\d+)/.exec(info.stderr); + nFrames = m ? Number(m[1]) : -1; + } catch { /* ignore */ } + // ★ 实测语义:截帧 ⇒ 成片时长比官方时间线短 ⇒ 【验收不通过】并报错 + // ⇒ 正确行为是"被抓住",不是"出片成功"(否则就是音画不对齐的坏片) + rec('make_film.maxFrames ⇒ 截帧导致时长对不齐 ⇒ 必须被验收拒绝(不是静默出坏片)', + r.result?.isError === true && /验收不通过|时长/.test(textOf(r)), + `isError=${r.result?.isError === true} 帧数=${nFrames} :: ${textOf(r).replace(/\s+/g, ' ').slice(-140)}`); + } + } else { + rec('make_film.lut / maxFrames', false, '没有草稿库(JYAI_DRAFT_LIB)⇒ 无法验证'); + } + + /* ── ⑥ make_film.beautify / targetLufs:需要【有音轨的草稿】才有得验 ── */ + if (HAS_AUDIO) { + // ⑥a beautify:人声美化(近似 EQ+压缩)⇒ 出片成功,且报告里体现 beautify 被应用 + const outB = path.join(WORK, 'mf-beautify'); + const rb = await rpc('tools/call', { + name: 'make_film', + arguments: { draft: DRAFT, outDir: outB, fps: 6, denoise: 'auto', beautify: 'approx' }, + }); + const bt = textOf(rb); + // ★ 诚实说明:这个夹具的音频是 440Hz 正弦 ⇒ 官方【不判定为人声】⇒ beautify 命中数必然是 0 + // ⇒ 这条验的是【参数通路 + 不影响出片】,不是"美化的效果"(后者需要真人人声素材) + rec('make_film.beautify ⇒ 参数被传下去且出片正常(命中数取决于素材是否被判定为人声 ⇒ 夹具是正弦 ⇒ 0)', + rb.result?.isError !== true && /流:\s*video, audio/.test(bt) && /beautify=\d+/.test(bt), + bt.replace(/\s+/g, ' ').slice(-200)); + + // ⑥b targetLufs:响度目标改成 -18 ⇒ 验收的"整体响度"必须对着 -18 判(而不是 -14) + const outL = path.join(WORK, 'mf-lufs18'); + const rl = await rpc('tools/call', { + name: 'make_film', + arguments: { draft: DRAFT, outDir: outL, fps: 6, denoise: 'auto', targetLufs: -18 }, + }); + const lt = textOf(rl); + rec('make_film.targetLufs ⇒ 响度目标被采用(验收对着目标判,且实测落在目标附近)', + rl.result?.isError !== true && /目标 -18/.test(lt) && /整体响度:\s*-1[78]/.test(lt), + lt.replace(/\s+/g, ' ').slice(-200)); + } else { + rec('make_film.beautify / targetLufs', false, '没有带音轨的夹具(JYAI能力批测-带音轨夹具)⇒ 无法验证'); + } + + /* ── ⑦ verify_diff.beforeVideo / afterVideo:音频维度要真的测出 LUFS ── */ + { + // 造两个带音轨的 mp4(响度不同 ⇒ 两个 LUFS 必须不同,否则测的就是空气) + const VA = path.join(WORK, 'a-tone.mp4'); + const VB = path.join(WORK, 'b-tone.mp4'); + await run(ffmpeg(), ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=gray:s=160x90:r=12:d=2', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000:duration=2', + '-filter:a', 'volume=-18dB', '-c:v', 'libx264', '-c:a', 'aac', '-shortest', VA]); + await run(ffmpeg(), ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=gray:s=160x90:r=12:d=2', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000:duration=2', + '-filter:a', 'volume=-6dB', '-c:v', 'libx264', '-c:a', 'aac', '-shortest', VB]); + const au = await rpc('tools/call', { + name: 'verify_diff', arguments: { checks: ['audio'], beforeVideo: VA, afterVideo: VB }, + }); + const m = /"integratedLufs"\s*:\s*(-?[\d.]+)/g; + const lufs = [...textOf(au).matchAll(m)].map((x) => Number(x[1])); + rec('verify_diff.beforeVideo/afterVideo ⇒ 音频维度真的测出两个不同的 LUFS', + lufs.length >= 2 && lufs[0] !== lufs[1], + `LUFS=${lufs.join(', ')} :: ${textOf(au).replace(/\s+/g, ' ').slice(0, 120)}`); + // ★ 缺参数时的行为:必须明说"跳过",不能装作测过了 + const noVid = await rpc('tools/call', { name: 'verify_diff', arguments: { checks: ['audio'] } }); + rec('verify_diff 缺 beforeVideo/afterVideo ⇒ 明说 skipped(不装作测过了)', + /skipped/.test(textOf(noVid)), textOf(noVid).replace(/\s+/g, ' ').slice(0, 140)); + } + + child.kill(); + try { fs.rmSync(WORK, { recursive: true, force: true }); } catch { /* ignore */ } + const pass = cases.filter((c) => c.ok).length; + return { pass, fail: cases.length - pass, total: cases.length, cases }; +} + +const isMain = process.argv[1] + && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); +if (isMain) { + const r = await runParamsCheck(); + console.log('=== 参数覆盖自检 ===\n'); + for (const c of r.cases) console.log(` [${c.ok ? 'PASS' : 'FAIL'}] ${c.name}${c.ok ? '' : ' — ' + c.detail}`); + console.log(`\n=== ${r.pass} PASS / ${r.fail} FAIL ===`); + process.exit(r.fail ? 1 : 0); +} diff --git a/plugins/antianqi/jianying/scripts/selftest-subtitle.mjs b/plugins/antianqi/jianying/scripts/selftest-subtitle.mjs new file mode 100644 index 00000000..07008b2a --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-subtitle.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * jianying 插件:字幕落稿自检 + * 造一份 SRT → add-subtitles.mjs 落进副本草稿 → 回读核对 + * ★ 判据是【语义】:回读草稿里的 text 段,时码必须与 SRT 吻合(容差 ±1 帧,官方按 fps 帧对齐) + * ★ 负向注入:同目录的"无字幕"草稿必须判为"没有字幕"(不能把探针写成恒真) + * + * 用法: node scripts/selftest-subtitle.mjs + * 需要: JYAI_HOME 指向 JYAI 仓库;剪映 11.6 在运行;JYAI_TEST_DRAFT 指向一个带视频片段的草稿 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +const DRAFT = process.env.JYAI_TEST_DRAFT || ''; +const WORK = path.join(JYAI, 'out', 'p67', 'subtitle-selftest'); + +let PASS = 0, FAIL = 0; +const check = (n, ok, d) => { + if (ok) { PASS++; console.log(` [PASS] ${n}${d ? ' — ' + d : ''}`); } + else { FAIL++; console.log(` [FAIL] ${n}${d ? ' — ' + d : ''}`); } +}; +function call(verb, args) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, 'run-verb.mjs'), verb, JSON.stringify(args)], + { env: { ...process.env, JYAI_HOME: JYAI }, stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => { + let j = null; try { j = JSON.parse(so); } catch { /* not JSON */ } + resolve({ code, json: j, text: so, stderr: se, failed: code !== 0 || j?.isError === true }); + }); + }); +} +const run = (script, args) => new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, script), ...args], { env: { ...process.env, JYAI_HOME: JYAI }, stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => resolve({ code, out: so + se })); +}); + +console.log('=== 字幕落稿自检 ===\n'); +if (!DRAFT) { + console.log(' [SKIP] 需要 JYAI_TEST_DRAFT —— 指向一个带视频片段的剪映草稿目录。'); + console.log(' 例(PowerShell): $env:JYAI_TEST_DRAFT = "<草稿库>\\<草稿名>"'); + process.exit(0); +} +fs.mkdirSync(WORK, { recursive: true }); + +const CUES = [ + { text: '大家好,今天我们来聊一个被很多人忽略的问题', startMs: 500, endMs: 3000 }, + { text: '就是怎么让 AI 直接操作剪映', startMs: 3200, endMs: 6500 }, + { text: '嗯,这个问题其实挺有意思的', startMs: 6800, endMs: 9500 }, +]; +const srtPath = path.join(WORK, 'selftest.srt'); +const srt = CUES.map((c, i) => { + const ts = (ms) => { + const h = String(Math.floor(ms / 3600000)).padStart(2, '0'); + const m = String(Math.floor((ms % 3600000) / 60000)).padStart(2, '0'); + const s = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); + return `${h}:${m}:${s},${String(ms % 1000).padStart(3, '0')}`; + }; + return `${i + 1}\n${ts(c.startMs)} --> ${ts(c.endMs)}\n${c.text}\n`; +}).join('\n'); +fs.writeFileSync(srtPath, srt, 'utf8'); +check('① 造 SRT', fs.existsSync(srtPath) && CUES.length === 3, srtPath); + +// ② 落稿 +const r = await run('add-subtitles.mjs', ['--srt', srtPath, '--draft', DRAFT, '--apply']); +const okMatch = /落稿: (\d+) 成功 \/ (\d+) 失败/.exec(r.out); +const landed = okMatch ? +okMatch[1] : 0; +const failedN = okMatch ? +okMatch[2] : -1; +check('② add-subtitles 落稿', landed === CUES.length && failedN === 0, r.out.replace(/\s+/g, ' ').match(/落稿:.*$/)?.[0] || r.out.slice(0, 160)); + +// ③ 验收行:时码吻合数 +const accMatch = /时码吻合 (\d+)\/(\d+)/.exec(r.out); +const matched = accMatch ? +accMatch[1] : 0; +check('③ 时码与 SRT 吻合(语义判据,±1 帧)', matched === CUES.length, accMatch ? `${matched}/${CUES.length}` : '验收行缺失'); + +// ④ 阴性:没跑之前草稿里没有这些字幕 ⇒ 探针必须能区分"有字幕"和"没字幕" +const open = await call('draft_open', { draft: DRAFT, copy: true }); +let before = null; +try { before = JSON.parse(open.text).draft; } catch { /* ignore */ } +const textSegsBefore = []; +for (const t of before?.tracks || []) if (t.type === 'text') for (const s of t.segments || []) textSegsBefore.push(s); +const beforeHas = textSegsBefore.some((s) => CUES.some((c) => s.target_timerange?.start === c.startMs * 1000)); +check('★ ④ 阴性:原始草稿里没有这些字幕(探针有判别力)', !beforeHas, + `原始草稿 text 段 ${textSegsBefore.length} 条 · 含目标时码 = ${beforeHas}`); + +console.log(`\n=== ${PASS} PASS / ${FAIL} FAIL ===`); +process.exit(FAIL === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/selftest-talk-cut.mjs b/plugins/antianqi/jianying/scripts/selftest-talk-cut.mjs new file mode 100644 index 00000000..57fc341a --- /dev/null +++ b/plugins/antianqi/jianying/scripts/selftest-talk-cut.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * jianying-talk-cut 自检:规划器判定 + 落稿链 + * ① 规划器:水词/长停顿/重复 三类判定 + 保留区间算对 + * ② 落稿链:split×2+delete ⇒ 时间线正好缩短 + * 用法: node scripts/selftest-talk-cut.mjs + */ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const JYAI = process.env.JYAI_HOME || path.join(os.homedir(), 'JYAI'); +const WORK = path.join(JYAI, 'out', 'p67', 'talk-cut-selftest'); +// ★ 草稿路径不写死任何人的盘符:必须由调用方用 JYAI_TEST_DRAFT 指定。 +// 没给时,① 规划器照跑(纯离线),只有 ② 落稿链(需要活的剪映)SKIP。 +const DRAFT = process.env.JYAI_TEST_DRAFT || ''; + +let PASS = 0, FAIL = 0; +const check = (n, ok, d) => { + if (ok) { PASS++; console.log(` [PASS] ${n}${d ? ' — ' + d : ''}`); } + else { FAIL++; console.log(` [FAIL] ${n}${d ? ' — ' + d : ''}`); } +}; +fs.mkdirSync(WORK, { recursive: true }); + +/* ---------- 造一份含水词/长停顿/重复的 SRT ---------- */ +const SRT = path.join(WORK, 'test.srt'); +fs.writeFileSync(SRT, [ + '1', '00:00:00,500 --> 00:00:03,200', '嗯,那个,今天我们来聊一聊这个青椒的辣度。', '', + '2', '00:00:03,400 --> 00:00:05,800', '其实青椒肉丝是特别经典的一道家常菜。', '', + '3', '00:00:06,900 --> 00:00:09,000', '呃,那个,我个人非常喜欢这个菜。', '', + '4', '00:00:09,100 --> 00:00:12,000', '其实青椒肉丝是特别经典的一道家常菜。', '', + '5', '00:00:13,800 --> 00:00:16,200', '这道菜的关键是青椒要够脆、肉丝要够嫩。', '', + '6', '00:00:16,400 --> 00:00:18,900', '嗯,就是说,火候一定要掌握好。', '', + '7', '00:00:19,300 --> 00:00:20,100', '嗯,呃。', '', +].join('\n'), 'utf8'); + +console.log('=== talk-cut 自检 ===\n'); + +/* ---------- ① 规划器 ---------- */ +const planFile = path.join(WORK, 'plan.json'); +const p = spawnSync(process.execPath, [path.join(HERE, 'talk-cut-plan.mjs'), + '--srt', SRT, '--out', planFile], { encoding: 'utf8' }); +check('① 规划器跑通', p.status === 0, p.stdout.split('\n').filter((l) => l.includes('drop ')).join('').trim()); +const plan = JSON.parse(fs.readFileSync(planFile, 'utf8')); +const byIdx = (i) => plan.cues.find((c) => c.index === i); + +check('①a 纯水词句判 drop', byIdx(7)?.verdict === 'drop', `#7 = ${byIdx(7)?.verdict}(${byIdx(7)?.reasons?.join('/')})`); +check('①b 含水词但有实词 → review', byIdx(1)?.verdict === 'review', `#1 = ${byIdx(1)?.verdict}`); +check('①c 隔一条的重复被检出', (plan.duplicates || []).some((d) => d.cue === 4 && d.ofCue === 2), + JSON.stringify(plan.duplicates)); +check('①d 长停顿被检出', (plan.gaps || []).length >= 2, `${(plan.gaps || []).length} 处`); +check('①e 词级水词有时间定位', (byIdx(1)?.fillerSpans || []).length > 0 && byIdx(1).fillerSpans[0].endMs > byIdx(1).fillerSpans[0].startMs, + JSON.stringify((byIdx(1)?.fillerSpans || []).slice(0, 2))); +const keepTotal = plan.keepRanges.reduce((a, r) => a + (r.endMs - r.startMs), 0); +const dropTotal = plan.dropRanges.reduce((a, r) => a + (r.endMs - r.startMs), 0); +check('①f 保留 + 删除 = 总时长', keepTotal + dropTotal === plan.stats.totalMs, + `${keepTotal} + ${dropTotal} = ${keepTotal + dropTotal} vs ${plan.stats.totalMs}`); + +/* ---------- ② 落稿链(需要活的剪映 + 一个真实草稿)---------- */ +if (!DRAFT) { + console.log(' [SKIP] ② 落稿链 —— 需要 JYAI_TEST_DRAFT(指向一个带视频片段的剪映草稿目录)且剪映在运行。'); + console.log(' 例(PowerShell): $env:JYAI_TEST_DRAFT = "<草稿库>\\<草稿名>"'); +} else { + const cutPlan = path.join(WORK, 'cut-plan.json'); + fs.writeFileSync(cutPlan, JSON.stringify({ + ...plan, + dropRanges: [{ startMs: 1000, endMs: 2000, reason: '自检:剪掉第一段中间 1 秒' }], + }, null, 2), 'utf8'); + + const a = spawnSync(process.execPath, [path.join(HERE, 'talk-cut-apply.mjs'), + '--plan', cutPlan, '--draft', DRAFT, '--apply'], { encoding: 'utf8', timeout: 600000 }); + const out = a.stdout || ''; + check('② 落稿链跑通', a.status === 0, + out.split('\n').filter((l) => /判定|已验证剪掉|这一轨时长总量|\[OK\]|落盘/.test(l)).join(' | ').trim()); + // ★ 判据(2026-10-24 改写):这一轨的【时长总量】正好少了剪掉的长度 + // —— 旧判据是"总时长 == 原总时长 − 剪掉量",但①改前读的是磁盘形态、改后读的是会话快照形态 + // (段 11 键 vs 50+ 键)⇒ 两种形态混着比 ⇒ 假红;②官方删片段会把后面的段左移补空 + // ⇒ 总时长也不是可靠判据。现在判据形态无关、且与位移无关。 + check('②a 这一轨的时长总量正好少了剪掉的长度(★ 形态无关的判据)', + /判定: 已验证剪掉 1\/1 个区间/.test(out) && /这一轨时长总量 \d+ → \d+ us(正好少 1000000)/.test(out), + (out.match(/# 判定:.*/) || [''])[0].trim() + ' || ' + (out.match(/\[OK\].*/) || [''])[0].trim()); + check('②b 真草稿零改动', /realDraftUnchanged=true/.test(out), (out.match(/# 落盘:.*/) || [''])[0].trim()); + // ★ ②c 阴性对照:演练模式(没 --apply)⇒ 判据必然【不成立】 + // ⇒ 这一条证明判据【有区分力】:"剪了"和"没剪"结论不同(不是恒真) + const dry = spawnSync(process.execPath, [path.join(HERE, 'talk-cut-apply.mjs'), + '--plan', cutPlan, '--draft', DRAFT], { encoding: 'utf8', timeout: 600000 }); + const dryOut = dry.stdout || ''; + check('②c 阴性对照:演练模式 ⇒ 判据不成立(证明判据能区分"剪了"和"没剪")', + /阴性对照/.test(dryOut) && /判据现在【不成立】/.test(dryOut), + (dryOut.match(/阴性对照.*/) || [''])[0].trim()); +} + +console.log(`\n=== ${PASS} PASS / ${FAIL} FAIL ===`); +process.exit(FAIL === 0 ? 0 : 1); diff --git a/plugins/antianqi/jianying/scripts/talk-cut-apply.mjs b/plugins/antianqi/jianying/scripts/talk-cut-apply.mjs new file mode 100644 index 00000000..a35906f2 --- /dev/null +++ b/plugins/antianqi/jianying/scripts/talk-cut-apply.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +/** + * talk-cut 应用器:把 plan 里的 dropRanges 真的剪进草稿 + * + * ★ 剪"中间一段"的正确链(实测,2026-10-21): + * ① segment.split {segmentId, splitTime: t1} → 切成两段,【后半段拿新 id】 + * ※ split 会让【后面的段整体后移】⇒ 所以必须【从后往前】处理多个区间 + * ② segment.split {segmentId: 前半id, splitTime: t0} → 前半再切两半 + * ③ segment.delete {segmentId: 中间段id} → 时间线缩短 (t1 - t0) + * + * 用法: + * node scripts/talk-cut-apply.mjs --plan --draft "<草稿名>" [--apply] + * --apply 真的改草稿(默认只演练,只打印每一步计划) + * 草稿名可以是绝对路径。 + * ★ --sessionId :复用【别人】开好的会话(编排器 run_pipeline 就是这么接的) + * —— 不自己 draft_open、不自己 draft_commit(落盘归编排器的 draft_commit 步骤管)。 + * 不给 --sessionId 时行为不变(自己开、自己落盘)。 + * + * ★★★ 判据纪律(2026-10-24 重写,实测踩坑换来的): + * ① 【同源】:改前改后必须读同一份草稿。 + * 踩过的坑:会话快照(运行时展开形态,段 50+ 键)与磁盘回落(精简形态,段 11 键) + * 是两个东西;官方 readFile 在路径解析不出 workspace 时还会【静默返回剪映当前打开的 + * 另一份草稿】⇒ 拿两种形态混着比总时长,比出来的是误导。 + * ⇒ 本脚本【全程只用会话快照】:第一次 patch 之后快照就存在了,之后所有读都同源。 + * ② 【有区分力】:判据 = "剪完后再看,同轨道上【不再有任何段完整覆盖这个区间】"。 + * 阳性前提:剪之前该区间确实被某个段完整覆盖(否则无从下手)。 + * 阴性对照:演练模式(没 --apply)下这个判据【必然不成立】—— 脚本会当场自证。 + * ③ 【失败报红】:判定不通过 ⇒ 退出码非 0 ⇒ 上游编排器会把它当失败步骤,不假装成功。 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const has = (k) => argv.includes(k); + +const PLAN = arg('--plan', null); +const DRAFT = arg('--draft', null); +const SID = arg('--sessionId', null); +const APPLY = has('--apply'); +if (!PLAN || (!DRAFT && !SID)) { + console.error('用法: node talk-cut-apply.mjs --plan --draft "<草稿名>" [--apply] [--sessionId ]'); + process.exit(2); +} + +/* ---- 走插件自带的 MCP 客户端(与 mcode 加载插件时同一套动词)---- */ +function call(verb, args) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(HERE, 'run-verb.mjs'), verb, JSON.stringify(args)], + { stdio: ['ignore', 'pipe', 'pipe'] }); + let so = '', se = ''; + c.stdout.on('data', (d) => { so += d; }); + c.stderr.on('data', (d) => { se += d; }); + c.on('close', (code) => { + let j = null; try { j = JSON.parse(so); } catch { /* not JSON */ } + resolve({ code, json: j, text: so, stderr: se, failed: code !== 0 || j?.isError === true }); + }); + }); +} + +const segsOf = (summary) => { + const out = []; + for (const t of (summary?.tracks || [])) for (const s of (t.segments || [])) { + out.push({ track: t.type, trackId: t.id, id: s.id, start: s.target_timerange?.start || 0, dur: s.target_timerange?.duration || 0, + keys: Object.keys(s).length }); + } + return out; +}; +const totalUs = (segs) => segs.reduce((a, s) => Math.max(a, s.start + s.dur), 0); +/* ★ 某一轨道上【所有段的时长之和】(判据用:与位移无关) + —— 官方删除片段时会【把后面的段左移补空】⇒ "该区间不再被覆盖"会误判(后面的段又盖上来了), + 而"这一轨的时长总量少了多少"不受位移影响。 */ +const trackSumUs = (segs, trackId) => segs.filter((s) => s.trackId === trackId).reduce((a, s) => a + s.dur, 0); +/* 形态指纹:同一段在"精简形态"下只有 11 个键,"运行时展开形态"有 50+ 个 */ +const formOf = (segs) => (segs[0] ? (segs[0].keys > 20 ? 'expanded' : 'compact') : 'unknown'); +/** 完整覆盖 [t0,t1] 的段 */ +const covering = (segs, t0, t1) => segs.find((s) => s.start <= t0 + 1 && s.start + s.dur >= t1 - 1); + +const plan = JSON.parse(fs.readFileSync(PLAN, 'utf8')); +const drops = (plan.dropRanges || []).slice().sort((a, b) => b.startMs - a.startMs); // ★ 从后往前 +console.log('=== talk-cut 应用 ==='); +console.log(` 计划: ${drops.length} 个待删区间 · 总时长 ${(plan.stats.totalMs / 1000).toFixed(1)}s`); +console.log(' 模式: %s', APPLY ? '★ 真改草稿' : '演练(只打印)'); +console.log(' 草稿: %s\n', DRAFT || '(复用会话,不指定)'); + +if (!drops.length) { console.log('没有要删的区间(plan 里 dropRanges 为空)'); process.exit(0); } + +/* ★ 会话来源:① 复用别人开好的(--sessionId)② 自己开一个 + —— 编排器走 ①:不然会跟编排器的 session 抢,两个进程各改各的。 */ +let sid = SID; +let copyDir = null; +if (sid) { + console.log('# 复用会话 %s(不自己开、不自己落盘)\n', sid); +} else { + const open = await call('draft_open', { draft: DRAFT, copy: true }); + if (open.failed) { console.error('打开草稿失败:\n' + (open.text + open.stderr).slice(0, 400)); process.exit(3); } + sid = open.json.sessionId; + copyDir = open.json.dir; + console.log('# session', sid, '\n'); +} + +/** 读当前草稿(会话快照优先 ⇒ 同源;还没有快照时才是磁盘回落) */ +async function readNow() { + const r = await call('draft_read', { sessionId: sid }); + if (r.failed) return { segs: [], source: 'read-failed', id: null, mismatch: false, text: (r.text + r.stderr).slice(0, 200) }; + return { segs: segsOf(r.json?.summary), source: r.json?.summary?.source || '?', + id: r.json?.summary?.draftId || null, mismatch: r.json?.summary?.draftIdMismatch === true }; +} + +let cur = await readNow(); +// ★ 同源校验:同一个会话里读到的草稿 id 必须一致(变了 ⇒ 我们在剪两份不同的草稿) +let curId = cur.id; +const idChanged = (r) => curId && r.id && r.id !== curId; +console.log('# 初始视图: %d 段 · 时间线 %d us · 形态 %s · 来源 %s · 草稿 id %s\n', + cur.segs.length, totalUs(cur.segs), formOf(cur.segs), cur.source, cur.id || '(未知)'); + +const done = []; +const failed = []; +for (const d of drops) { + const t0 = d.startMs * 1000, t1 = d.endMs * 1000; // ms → us + const planText = (c) => `split(${String(c.id).slice(0, 8)}, ${t1}) → split(front, ${t0}) → delete(mid)`; + + // ① 阳性前提:待剪区间在时间线上确实被某个段完整覆盖 + let cover = covering(cur.segs, t0, t1); + if (!cover) { + console.log(' [SKIP] 区间 %d..%d us 在时间线上找不到完整覆盖它的段 ⇒ 这一刀无从下手(不算剪掉)', t0, t1); + failed.push({ t0, t1, why: 'no-cover-segment' }); + continue; + } + const cutUs = t1 - t0; + const trackBefore = trackSumUs(cur.segs, cover.trackId); + if (!APPLY) { + // ★ 阴性对照:什么都没剪 ⇒ "这一轨的时长总量没少" ⇒ 判据必然不成立 ⇒ 判据有区分力 + console.log(' [DRY] %d..%d us ← %s', t0, t1, planText(cover)); + console.log(' 阴性对照:演练没动草稿 ⇒ 这一轨时长总量仍为 %d us(判据现在【不成立】⇒ 判据能区分)', trackBefore); + done.push({ t0, t1, dry: true }); + continue; + } + + // ② split @ t1 —— ★ 必须"段数增加"才算这一刀生效(官方 accepted 不算数) + // 如果段数没增加 ⇒ 这个 id 运行时认不出来(我们读到的和它要改的不是同一份草稿) + // ⇒ 此时会话快照已经存在了(patch 总会写快照)⇒ 用快照里的覆盖段重试一次。 + let sp = await call('draft_patch', { op: 'segment.split', sessionId: sid, params: { segmentId: cover.id, splitTime: t1 } }); + if (sp.failed) { + console.log(` [FAIL] split@${t1}:${(sp.text + sp.stderr).slice(0, 160)}`); + failed.push({ t0, t1, why: 'split1-failed' }); + continue; + } + const nBefore = cur.segs.length; + cur = await readNow(); + if (idChanged(cur)) { + console.log(' [FAIL] 读到的草稿 id 变了(%s → %s)⇒ 我们在剪两份不同的草稿 ⇒ 停手', curId, cur.id); + failed.push({ t0, t1, why: 'draft-id-changed' }); + break; + } + if (cur.segs.length <= nBefore) { + const cover2 = covering(cur.segs, t0, t1); + if (!cover2 || cover2.id === cover.id) { + console.log(' [FAIL] split@%d 后段数没增加(%d 段)且换不到别的覆盖段 ⇒ 这一刀没生效', t1, cur.segs.length); + failed.push({ t0, t1, why: 'split1-noop' }); + continue; + } + console.log(' · 换覆盖段重试:%s → %s', String(cover.id).slice(0, 8), String(cover2.id).slice(0, 8)); + cover = cover2; + sp = await call('draft_patch', { op: 'segment.split', sessionId: sid, params: { segmentId: cover.id, splitTime: t1 } }); + if (sp.failed) { + console.log(` [FAIL] split@${t1}(重试):${(sp.text + sp.stderr).slice(0, 160)}`); + failed.push({ t0, t1, why: 'split1-retry-failed' }); + continue; + } + cur = await readNow(); + if (idChanged(cur)) { + console.log(' [FAIL] 重试后读到的草稿 id 变了 ⇒ 停手'); + failed.push({ t0, t1, why: 'draft-id-changed' }); + break; + } + if (cur.segs.length <= nBefore) { + console.log(' [FAIL] split@%d 重试后段数仍没增加 ⇒ 这一刀没生效', t1); + failed.push({ t0, t1, why: 'split1-retry-noop' }); + continue; + } + } + console.log(` ① split@${t1} → ${cur.segs.length} 段`, cur.segs.map((s) => `${String(s.id).slice(0, 6)}@${s.start}+${s.dur}`).join(' ')); + + // ③ split @ t0 + const front = cur.segs.find((s) => s.id === cover.id); + if (!front) { + console.log(' [FAIL] split 后找不到前半段(%s)', String(cover.id).slice(0, 8)); + failed.push({ t0, t1, why: 'front-not-found' }); + continue; + } + let sp2 = await call('draft_patch', { op: 'segment.split', sessionId: sid, params: { segmentId: front.id, splitTime: t0 } }); + if (sp2.failed) { + console.log(` [FAIL] split@${t0}:${(sp2.text + sp2.stderr).slice(0, 160)}`); + failed.push({ t0, t1, why: 'split2-failed' }); + continue; + } + cur = await readNow(); + console.log(` ② split@${t0} → ${cur.segs.length} 段`, cur.segs.map((s) => `${String(s.id).slice(0, 6)}@${s.start}+${s.dur}`).join(' ')); + + // ④ 找中间段(被挖掉的那一段)—— ★ 必须【同轨道】(别的轨道上也有段会落在这个时间窗里) + const mid = cur.segs.find((s) => s.id !== front.id && s.start >= t0 - 1 && s.start + s.dur <= t1 + 1 && s.dur > 0 + && s.trackId === cover.trackId); + if (!mid) { + console.log(` [FAIL] 找不到中间段(期望 start>=${t0 - 1} 且 start+dur<=${t1 + 1} 且同轨道)`); + failed.push({ t0, t1, why: 'mid-not-found' }); + continue; + } + + // ⑤ delete 中间段 + const del = await call('draft_patch', { op: 'segment.delete', sessionId: sid, params: { segmentId: mid.id } }); + if (del.failed) { + console.log(' [FAIL] delete:%s', (del.text + del.stderr).slice(0, 160)); + failed.push({ t0, t1, why: 'delete-failed' }); + continue; + } + cur = await readNow(); + if (idChanged(cur)) { + console.log(' [FAIL] 删除后读到的草稿 id 变了 ⇒ 停手'); + failed.push({ t0, t1, why: 'draft-id-changed' }); + break; + } + + // ★★ 生效判据:这一轨的【时长总量】少了恰好 (t1-t0) + // (不用"区间不再被覆盖":官方删除片段会把后面的段左移补空 ⇒ 区间又被盖上了 ⇒ 那是误判) + const trackAfter = trackSumUs(cur.segs, cover.trackId); + const shrunk = trackBefore - trackAfter; + if (shrunk !== cutUs) { + console.log(' [FAIL] 剪完 %d..%d us 后这一轨的时长总量只少了 %d us(应为 %d us)⇒ 这一刀没生效(官方 accepted 不算数)', + t0, t1, shrunk, cutUs); + failed.push({ t0, t1, why: 'track-duration-unchanged' }); + continue; + } + console.log(' [OK] 剪掉 %d..%d us(%d us)· 这一轨时长总量 %d → %d us(正好少 %d)· 现在 %d 段', + t0, t1, cutUs, trackBefore, trackAfter, shrunk, cur.segs.length); + done.push({ t0, t1, ok: true }); +} + +const cut = drops.reduce((a, d) => a + (d.endMs - d.startMs) * 1000, 0); +const okCount = done.filter((x) => x.ok).length; +if (!APPLY) { + console.log(`\n# 演练结束:没改草稿(时间线仍 ${totalUs(cur.segs)} us)。加 --apply 真改。`); +} else { + console.log(`\n# 结果: ${cur.segs.length} 段 · 时间线 ${totalUs(cur.segs)} us · 计划剪掉 ${cut} us`); + console.log(`# 判定: 已验证剪掉 ${okCount}/${drops.length} 个区间 · 失败 ${failed.length} 个`); + console.log(`# (判据 = 这一轨的时长总量正好少了 (t1-t0) us;与位移无关;演练模式下必不成立)`); +} + +if (failed.length) { + console.error(`# ✗ 判定失败:${failed.map((f) => `${f.why}${f.t0 !== undefined ? `@${f.t0}..${f.t1}` : ''}`).join(', ')}`); + process.exit(4); // ★ 判定没过 ⇒ 非 0 退出 ⇒ 编排器会把它当失败步骤(不假装成功) +} + +if (APPLY && done.length) { + if (SID) { + // ★ 复用会话时【不落盘】:落盘是编排器 draft_commit 步骤的事(它还要做真草稿 sha 护栏) + console.log('# 落盘: 跳过(复用会话模式 ⇒ 由编排器的 draft_commit 步骤落盘)'); + console.log('# 副本目录: %s', copyDir || '(复用编排器开的副本)'); + } else { + const cm = await call('draft_commit', { sessionId: sid, realDraft: DRAFT }); + console.log('# 落盘: %s', cm.failed ? ('FAIL ' + (cm.text + cm.stderr).slice(0, 200)) + : `encrypted=${cm.json.encrypted} cipherType=${cm.json.cipherType} relocated=${cm.json.relocated} realDraftUnchanged=${cm.json.realDraftUnchanged}`); + console.log('# 副本目录: %s', copyDir); + } +} diff --git a/plugins/antianqi/jianying/scripts/talk-cut-plan.mjs b/plugins/antianqi/jianying/scripts/talk-cut-plan.mjs new file mode 100644 index 00000000..0324f349 --- /dev/null +++ b/plugins/antianqi/jianying/scripts/talk-cut-plan.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** + * talk-cut 规划器:转写稿(SRT) → 保留区间表 + * + * 这是"口播剪辑"的【经验层】:判断哪句是水词、哪里是长停顿、哪句是重复。 + * ★ 官方 ASR(AnalyzeAsset) 在 agent runtime 里、我们拿不到(见 §74.4), + * 所以本脚本吃的是【用户提供的 SRT / 转写稿】。 + * + * 用法: + * node scripts/talk-cut-plan.mjs --srt [--out ] + * [--filler-ms 700] [--dup-sim 0.72] [--keep-repeats] + * + * 判定分三级(保守优先): + * drop 整条只由水词/标点组成 ⇒ 建议整句删 + * review 句中有实词但夹水词 ⇒ 标出水词位置,建议人工确认 + * keep 正常内容 + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const argv = process.argv.slice(2); +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const has = (k) => argv.includes(k); + +const SRT = arg('--srt', null); +if (!SRT) { console.error('用法: node talk-cut-plan.mjs --srt [--out plan.json]'); process.exit(2); } +const OUT = arg('--out', null); +const FILLER_MS = Number(arg('--filler-ms', 700)); // 长停顿阈值 +const DUP_SIM = Number(arg('--dup-sim', 0.72)); // 重复判定相似度 +const KEEP_REPEATS = has('--keep-repeats'); +const DUP_WINDOW = Number(arg('--dup-window', 3)); // 重复检测窗口(往前看几条) + +/* 默认水词表(中文口播):可 --filler-file 覆盖 */ +const DEFAULT_FILLERS = [ + '嗯', '呃', '额', '啊', '哦', '噢', '唉', '欸', '诶', '唔', + '那个', '这个', '就是', '然后', '所以说', '怎么说', '对吧', '是吧', '你知道', + '怎么说呢', '对不对', '好不好', '行不行', 'emmm', 'umm', 'ah', 'eh', 'hmm', 'uh', +]; +const FILLER_FILE = arg('--filler-file', null); +const FILLERS = FILLER_FILE + ? fs.readFileSync(FILLER_FILE, 'utf8').split(/\r?\n/).map((s) => s.trim()).filter(Boolean) + : DEFAULT_FILLERS; + +/* ---------------- SRT 解析 ---------------- */ +function tsToMs(s) { + const m = /^(\d+):(\d{2}):(\d{2})[,.](\d{1,3})$/.exec(s.trim()); + if (!m) return null; + return (+m[1]) * 3600000 + (+m[2]) * 60000 + (+m[3]) * 1000 + (+m[4].padEnd(3, '0')); +} +function parseSrt(text) { + const cues = []; + const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/); + for (const b of blocks) { + const lines = b.split('\n').map((l) => l.trim()).filter(Boolean); + if (!lines.length) continue; + let idx = -1; + let ti = lines.findIndex((l) => l.includes('-->')); + if (ti < 0) continue; + if (ti > 0 && /^\d+$/.test(lines[0])) { idx = +lines[0]; ti = ti; } + const [a, z] = lines[ti].split('-->'); + const startMs = tsToMs(a), endMs = tsToMs(z); + if (startMs === null || endMs === null) continue; + const body = lines.slice(ti + 1).join(' ').trim(); + cues.push({ index: idx >= 0 ? idx : cues.length + 1, startMs, endMs, text: body }); + } + return cues; +} + +/* ---------------- 文本工具 ---------------- */ +const PUNCT = /[\s,,。!?、;:""''()()《》<>\-—…·.!?;:"']/g; +const norm = (s) => s.replace(PUNCT, ''); +function bigrams(s) { + const t = norm(s); + const set = new Set(); + for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2)); + if (t.length === 1) set.add(t); + return set; +} +function jaccard(a, b) { + const A = bigrams(a), B = bigrams(b); + if (!A.size || !B.size) return 0; + let inter = 0; + for (const x of A) if (B.has(x)) inter++; + return inter / (A.size + B.size - inter); +} +function findFillers(text) { + const hits = []; + for (const f of FILLERS) { + let from = 0; + for (;;) { + const i = text.indexOf(f, from); + if (i < 0) break; + hits.push({ text: f, at: i }); + from = i + f.length; + } + } + hits.sort((a, b) => a.at - b.at); + // 去掉被更长水词覆盖的短命中 + const kept = []; + for (const h of hits) { + if (kept.some((k) => h.at >= k.at && h.at < k.at + k.text.length)) continue; + kept.push(h); + } + return kept; +} + +/* ---------------- 主流程 ---------------- */ +const raw = fs.readFileSync(SRT, 'utf8'); +const cues = parseSrt(raw); +if (!cues.length) { console.error('SRT 里没解析出任何字幕条目'); process.exit(3); } + +const outCues = []; +const dropRanges = []; +const dupOf = []; +const gaps = []; + +for (let i = 0; i < cues.length; i++) { + const c = cues[i]; + const content = norm(c.text); + const fh = findFillers(c.text); + const reasons = []; + + // ① 长停顿(相对上一条) + if (i > 0) { + const gap = c.startMs - cues[i - 1].endMs; + if (gap >= FILLER_MS) { + gaps.push({ startMs: cues[i - 1].endMs, endMs: c.startMs, ms: gap, afterCue: cues[i - 1].index }); + reasons.push(`长停顿 ${gap}ms`); + } + } + + // ② 重复(与最近 DUP_WINDOW 条比 —— 口播里"重录"常常隔一句,不只相邻) + if (i > 0 && !KEEP_REPEATS) { + let bestSim = 0, bestJ = -1; + for (let k = Math.max(0, i - DUP_WINDOW); k < i; k++) { + const sim = jaccard(c.text, cues[k].text); + if (sim > bestSim) { bestSim = sim; bestJ = k; } + } + if (bestSim >= DUP_SIM) { + reasons.push(`与第${cues[bestJ].index}条重复(相似度 ${bestSim.toFixed(2)})`); + dupOf.push({ cue: c.index, ofCue: cues[bestJ].index, similarity: +bestSim.toFixed(3) }); + } + } + + // ③ 水词 + if (fh.length) reasons.push(`含水词:${fh.map((f) => f.text).join('、')}`); + + // 判定 + let verdict = 'keep'; + const onlyFiller = fh.length > 0 && content.replace(new RegExp(FILLERS.map(escapeRe).join('|'), 'g'), '').length === 0; + if (onlyFiller && fh.length) { + verdict = 'drop'; + reasons.unshift('整条只有水词'); + } else if (reasons.length) { + verdict = 'review'; + } + + // 词级水词时间(按字符比例估算) + const fillerSpans = fh.map((f) => { + const ratio = c.text.length ? f.at / c.text.length : 0; + const span = c.text.length ? f.text.length / c.text.length : 0; + return { text: f.text, at: f.at, + startMs: Math.round(c.startMs + (c.endMs - c.startMs) * ratio), + endMs: Math.round(c.startMs + (c.endMs - c.startMs) * (ratio + span)) }; + }); + + outCues.push({ index: c.index, startMs: c.startMs, endMs: c.endMs, text: c.text, + verdict, reasons, fillerSpans }); + if (verdict === 'drop') dropRanges.push({ startMs: c.startMs, endMs: c.endMs, reason: reasons.join(' / ') }); +} +function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +/* 合并保留区间(把 drop 区间从总区间里挖掉) */ +function mergeRanges(rs) { + const s = rs.slice().sort((a, b) => a.startMs - b.startMs); + const out = []; + for (const r of s) { + if (out.length && r.startMs <= out[out.length - 1].endMs) { + out[out.length - 1].endMs = Math.max(out[out.length - 1].endMs, r.endMs); + } else out.push({ ...r }); + } + return out; +} +const totalStart = cues[0].startMs, totalEnd = cues[cues.length - 1].endMs; +const keepRanges = []; +let cur = totalStart; +for (const d of dropRanges.slice().sort((a, b) => a.startMs - b.startMs)) { + if (d.startMs > cur) keepRanges.push({ startMs: cur, endMs: d.startMs }); + cur = Math.max(cur, d.endMs); +} +if (cur < totalEnd) keepRanges.push({ startMs: cur, endMs: totalEnd }); + +const droppedMs = dropRanges.reduce((a, r) => a + (r.endMs - r.startMs), 0); +const plan = { + schema: 'jianying.talk-cut.plan.v1', + source: path.resolve(SRT), + params: { fillerMs: FILLER_MS, dupSim: DUP_SIM, keepRepeats: KEEP_REPEATS, fillers: FILLERS }, + stats: { + cues: cues.length, + totalMs: totalEnd - totalStart, + drop: outCues.filter((c) => c.verdict === 'drop').length, + review: outCues.filter((c) => c.verdict === 'review').length, + keep: outCues.filter((c) => c.verdict === 'keep').length, + droppedMs, + droppedRatio: +((droppedMs / Math.max(1, totalEnd - totalStart))).toFixed(4), + }, + cues: outCues, + dropRanges, + keepRanges, + gaps, + duplicates: dupOf, +}; + +console.log('=== talk-cut 规划 ==='); +console.log(` 字幕条数 ${plan.stats.cues} · 总时长 ${(plan.stats.totalMs / 1000).toFixed(1)}s`); +console.log(` drop ${plan.stats.drop} · review ${plan.stats.review} · keep ${plan.stats.keep}`); +console.log(` 建议删除 ${(droppedMs / 1000).toFixed(2)}s(${(plan.stats.droppedRatio * 100).toFixed(1)}%)`); +console.log(` 长停顿 ${gaps.length} 处 · 重复 ${dupOf.length} 处`); +for (const c of outCues.filter((x) => x.verdict !== 'keep').slice(0, 12)) { + console.log(` [${c.verdict.toUpperCase()}] #${c.index} ${(c.startMs / 1000).toFixed(1)}s ${c.reasons.join(' / ')}`); +} +if (OUT) { + fs.writeFileSync(OUT, JSON.stringify(plan, null, 2), 'utf8'); + console.log('\n# -> ' + OUT); +} else { + console.log('\n(加 --out 保存计划)'); +} diff --git a/plugins/antianqi/jianying/skills/jianying-cut/SKILL.md b/plugins/antianqi/jianying/skills/jianying-cut/SKILL.md new file mode 100644 index 00000000..2538a45d --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-cut/SKILL.md @@ -0,0 +1,144 @@ +--- +name: jianying-cut +description: 'Use when: 给视频按调色参数或风格出片 / 给剪映草稿一条命令出成片 / 用复刻的剪映调色链出 10bit 成片 / 按暖色、清爽、电影感、明亮风格调色 / 调色后出带原声的片 / 调完对比成片画面变没变. 触发词: 给这个视频调色出片 / 把这个剪映草稿出成片 / 草稿导出成片 / 按XX风格调色 / 出个10bit的片 / 剪映调色 / 调色后出片 / 出带声音的片 / 剪映出片 / 调完对比画面变没变.' +displayNames: + zh-Hans: 剪映调色出片(10bit · 保留原声) +license: Apache-2.0 +compatibility: Requires Windows + ffmpeg 6.0 + Python 3.9+ + JYAI_HOME 指向 JYAI 仓库(工具链在 JYAI_HOME/tools)+ 剪映 11.6 运行时缓存(读取官方 shader 用;出片本身不依赖剪映 GUI)。 +metadata: + author: antianqi + version: "0.1.0" + basis: "调色链 10 个节点逐像素对齐官方 shader(误差 ≤1 LSB);10bit/16F 出片链路已验证" +--- + +# 剪映调色出片(jianying-cut) + +> **一句话定位**:给一个源片,按调色参数或风格预设,用**逐像素复刻的剪映调色链**出 **10bit / 保留原音轨**的成片, +> 附「原片 | 调色后」对比图与逐节点生效明细。**不需要剪映 GUI。** + +## 调什么(动词与能力) + +本 skill 只动一个动词:**`grade_apply`**(调色 + 出片)。 +配套两个:**`verify_diff`**(验收改动是否可见)、**`render_frames`**(用官方渲染器出帧)。 + +★ **另有一个"整条出片链"动词 `make_film`**:给**剪映草稿**,一条命令出成片 +(官方渲染器出画面 → 可选我们的 3D LUT → 我们的混音链(音量/淡入淡出/降噪/响度归一)→ ffmpeg 编码 → 带断言的验收)。 +**画面是官方渲染器出的**(不是我们自己算的),**音频是我们自己混的**。 + +| 想做的事 | 调哪个动词 | 关键参数 | +|---|---|---| +| 调亮度/对比/饱和/色温… | `grade_apply` | `spec.adjustColor.*` | +| 分色调整 | `grade_apply` | `spec.hsl.<颜色名>.{hue,saturation,lightness}` | +| 对数轮 / 三原色轮 | `grade_apply` | `spec.logWheel.*` / `spec.primaryWheel.*` | +| 曲线 / HSL 耦合曲线 | `grade_apply` | `spec.curves.*` / `spec.hsl_curves.*` | +| 锐化 / 暗角 / 颗粒 | `grade_apply` | `spec.sharpen` / `spec.vignetting` / `spec.particle` | +| 确认改动看得见 | `verify_diff` | `beforeDir` / `afterDir` | +| **给剪映草稿出成片(含音轨)** | **`make_film`** | `draft` / `outDir` / `grade` / `lut` / `denoise` / `beautify` / `targetLufs` | +| 官方导出器出片 | —— | 官方 CLI 侧被 −100007 / −100015 挡死,**不绕**;我们用 `make_film` 自己出 | + +★ **参数怎么填** → `references/chain-api.md`(语义在这里)· `mcp/tools/grade-apply.schema.json`(硬校验在这里) +★ **出片动词** → `mcp/tools/make-film.schema.json` +★ **多少算"生效"** → `references/verify-discipline.md` +★ **别再踩** → `references/pitfalls.md` + +## 主工作流 + +**入口**:`scripts/run-verb.mjs`(本插件自带的 MCP 客户端 ⇒ 走的是**和 mcode 加载插件时完全一样的动词**) + +```bash +# ① 环境自检(可选但建议) +node ${PLUGIN_ROOT}/scripts/check-env.mjs + +# ② 出片 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs grade_apply '{ + "src": "<源视频.mp4 的绝对路径>", + "outDir": "<输出目录的绝对路径>", + "frames": 12, + "spec": { "adjustColor": { "brightness": 0.10, "contrast": 0.12, "saturation": 0.28, "temperature": 0.15 } } +}' + +# ②' 或者:给剪映草稿直接出成片(官方画面 + 我们的调色 + 我们的混音) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{ + "draft": "<剪映草稿目录>", + "outDir": "<输出目录>", + "denoise": "auto", + "grade": { "curves": { + "red": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "green": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "blue": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1], + "luma": [0,0, 0.1,0.0533, 0.2,0.1067, 0.3,0.16, 0.4067,0.3933, 0.5133,0.6267, 0.62,0.86, 0.7467,0.9067, 0.8733,0.9533, 1,1, 1,1, 1,1] + } } +}' +``` + +★ **`grade` 的曲线参数格式见 `references/chain-api.md` 的 `curves` 段**(官方 A,L,R 布局,每控制点 6 个数; +R/G/B/Y **四条必须一起给**,缺一条会直接报错,不会静默烘成恒等)。 +★ **不需要**在剪映里存带曲线的草稿 —— 曲线参数由我们自己的接口给。 + +# ③ 验收(改动是否在画面上可见) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir": "<改前帧目录>", "afterDir": "<改后帧目录>" +}' +``` + +**流程**: +``` +健康检查(check-env) + ↓ +grade_apply:出 10bit 成片 + 逐节点生效探针(任一节点为 0 ⇒ 动词直接判失败) + ↓ +读产物:graded10.mp4(逐点直算,推荐)/ graded_lut.mp4(65³ LUT 形态) + ↓ +需要时 verify_diff:max-over-frames 判"看得见" +``` + +**产物**(在 `outDir` 下): +| 文件 | 内容 | +|---|---| +| `graded10.mp4` | **hevc / yuv420p10le / 原音轨**(逐点直算,推荐交付) | +| `graded_lut.mp4` | 同上,LUT 形态(链式插值误差 2 LSB) | +| `grade-side-by-side.png` | 左 原片 / 右 调色后 | +| `grade-compare.json` | 逐节点探针 · 调色改变量 · LUT vs 直算差 · 10bit 回读误差 | + +## 常见诉求 → spec 速查 + +> ★ **机器可读版**:`references/intent-map.json`(21 条"人话 → 参数"映射) +> ★ 里面的参数名由机械审计**按 schema 校验** ⇒ 拼错会被当场抓出来(不会静默失效)。 + +| 用户说 | spec | +|---|---| +| 调亮一点 | `{"adjustColor":{"brightness":0.12}}` | +| 调暖一点 | `{"adjustColor":{"temperature":0.15}}` | +| 提亮中间调、压高光 | `{"adjustColor":{"shadow":0.15,"highlight":-0.10}}` | +| 更有食欲(餐饮) | `{"adjustColor":{"saturation":0.28,"temperature":0.15,"contrast":0.12},"logWheel":{"shadow":{"r":0.14,"g":0.06,"b":0.0,"s":0.08}}}` | +| 电影感 | `{"adjustColor":{"contrast":0.22,"saturation":-0.10,"shadow":0.16},"vignetting":{"intensity":0.35,"param":0.30}}` | +| 更锐 | `{"sharpen":{"intensity":0.5}}` | +| 红色更艳 | `{"hsl":{"HSLRed":{"hue":0.56,"saturation":0.62,"lightness":0.55}}}` | + +## 失败处理 + +| 症状 | 原因 | 处理 | +|---|---|---| +| `入参不合法:未知键` | spec 里写了没这个节点 | 查 `references/chain-api.md` 的 10 节点表 | +| `亮度 5 > 最大 1` | 强度语义是 −1..1,不是 0..100 | 换算:界面 0..100 ⇒ 强度 = (v/100)*2 − 1 | +| `下列节点没有生效(探针为 0)` | 该节点被静默跳过(**参数形态不对**) | 单独调该节点,看 `grade-compare.json` | +| `但源画面几乎全黑(YAVG=…)⇒ 这是【源没有可测信息】` | ★ **源没有可测信息**,不是节点没生效(加亮类调色会被 clip 回 0) | 换一个有大面积中间调/亮部的源再验 | +| `调色改变量为 0` | spec 空 / 全 0 | 至少给一个非零强度 | +| `缺 JYAI 工具` | `JYAI_HOME` 没设 | `set JYAI_HOME=` | +| `UnicodeEncodeError gbk` | 环境码页 | 脚本已自设 UTF-8;若仍出现,检查调用方是否另起进程 | + +## ★ 三条纪律(别破) + +1. **本 skill 不直接改 `draft_content.json`** —— 改草稿请用 `jianying-draft-edit`(走 `draft.*` 动词)。 +2. **不自己算像素差** —— 一律用 `verify_diff`(max-over-frames),不抽单帧。 +3. **不绕过护栏** —— `grade_apply` 内建的探针、`draft_commit` 的 sha 断言是**故意拦人**的,不要关掉。 + +## 边界(不做什么) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| 改剪映草稿 | 本 skill 只出片 | `jianying-draft-edit` | +| 混音 / 加 BGM / 响度归一 | 另一条链 | `audio_mix` 动词(需草稿目录) | +| 上传多平台 | 平台侧各有一套 | 平台自己的 skill | +| 官方导出器出片 | 官方 CLI 侧被 −100007 / −100015 挡死,**不绕** | 我们自己出(就是本 skill) | +| 曲线/HSL/色轮**写回剪映** | 草稿侧字段名尚未确证,**且官方预览渲染器本身也不认这些**(实测只认强度型调色)⇒ 这条路对曲线类是关的 | 只能本地出片(`make_film --grade`;见 `references/pitfalls.md`) | diff --git a/plugins/antianqi/jianying/skills/jianying-draft-edit/SKILL.md b/plugins/antianqi/jianying/skills/jianying-draft-edit/SKILL.md new file mode 100644 index 00000000..59e4e2ca --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-draft-edit/SKILL.md @@ -0,0 +1,144 @@ +--- +name: jianying-draft-edit +description: 'Use when: 改剪映草稿 / 给草稿加素材或音轨 / 移动裁剪片段 / 调某段的音量 / 加文字 / 加轨道 / 撤销重做 / 改完落盘并回读草稿字段确认. 触发词: 给这个草稿加个片段 / 把这条素材剪短 / 把这轨静音 / 加个文字 / 改完存一下 / 撤销刚才那步 / 改完回读草稿字段对不对.' +displayNames: + zh-Hans: 剪映·改草稿(官方行为操作全集 · 加字幕 · 落盘不动真草稿) +license: Apache-2.0 +compatibility: Requires Windows + 剪映 11.6 在运行(edit 域走官方命名管道)+ JYAI_HOME 指向 JYAI 仓库(落盘/渲染/验收在 JYAI_HOME/tools)+ ffmpeg + Python 3.9+. +metadata: + author: antianqi + version: "0.1.0" + basis: "官方 edit 域带命名空间的行为操作全集(实测 18 域 199 个、桥全部放行,全量见 references/official-ops.md);落盘走 draft/writeFile;验收走【语义】逐段字段差(像素差需先量噪声底)" +--- + +# 剪映改草稿(jianying-draft-edit) + +> **一句话定位**:在**副本**上改剪映草稿(加素材/加音轨/移动裁剪/调音量/加文字/加轨道/撤销重做), +> 落盘后用**落盘草稿的逐段字段差**确认改动真的生效 —— **真草稿零改动**。 + +## 调什么(动词) + +| 动词 | 作用 | +|---|---| +| `draft_open` | 打开草稿(**默认开副本**;`allowReal` 与 `copy` 冲突会被拒) | +| `draft_read` | 读草稿语义结构(时间线/片段/素材)—— 拿 `segmentId` 等 id 用 | +| `draft_patch` | **官方行为操作全集**(18 域 / 199 个,桥**全部放行**;常用见下表,全量见 `references/official-ops.md`) | +| `draft_commit` | 落盘(自动**素材路径重定位** + **真草稿 sha 前后断言**) | +| `render_frames` | 用官方渲染器出帧(改前/改后各一次) | +| `verify_diff` | 逐帧像素差(**取全帧最大**)判定"看得见" | + +## ★ 官方行为操作(带命名空间,**不是裸名**) + +> **★ 全集是 18 域 / 199 个 op,我们这道桥实测全部放行(0 个被拒)** —— 以前"只有 27 个"是我们只试了 27 个。 +> 全量清单 + 已知参数契约 + 三个坑:`references/official-ops.md`。 + +| 组 | 操作 | +|---|---| +| `draft` | `setCanvasSize` · `setDraftId` | +| `track` | `add` · `delete` · `mute` | +| `segment` | `move` · `scale` · `translate` · `rotate` · `updateLayout` · `split` · `delete` · `setTimeRange` · `innerReplace` · `setDescription` | +| `video` | `add` · `muteOn` · `muteOff` · `setVolume` | +| `audio` | `add` · `setVolume` | +| `text` | `add` | +| `undoRedo` | `undo` · `redo` · `canUndo` · `canRedo` | +| 批量 | `invokeBatch` | + +**★ 已知必填项**(缺了会被本地先拦下,不白跑一次官方调用): +`video.add`→`path` · **`audio.add`→`audioPath`** · **`text.add`→`content`+`startTime`/`duration`** · `track.mute`/`track.delete`→`trackId` · +`segment.move`/`setDescription`→`segmentId` · **`segment.split`→`splitTime`** · +`segment.updateLayout`→`layout` · `segment.innerReplace`→`replacement` + +## 主工作流 + +```bash +# ① 开副本(拿 sessionId) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' + +# ② 读结构(拿 segmentId) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"path":"<草稿目录>/draft_content.json","root":"<草稿目录>"}' +# ★ 不要传 scope:后端不支持(分段读取未实现,传了会被 schema 拒) + +# ③ 改(可多次;每次 baseRevision 会递增) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_patch '{"op":"video.setVolume","sessionId":"ve-session-N","params":{"segmentId":"","volume":0.25}}' + +# ④ 落盘(会做路径重定位 + 真草稿 sha 断言) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_commit '{"sessionId":"ve-session-N","realDraft":"<草稿库>\\<草稿名>"}' # ★ sessionId 必填(draft_open 给的) + +# ⑤ 验收(★ 主判据 = 语义:落盘草稿的逐段字段差) +# 改前/改后各读一次明文草稿,再让 verify_diff 比"哪个字段变了" +# ★ 两份草稿必须是【裸草稿】——顶层就有 tracks/segments; +# draft_read 的响应外面还包了一层 draft ⇒ 直接把响应存成文件会读出 tracks:0(判据失效) +# ★ 两份草稿必须是【裸草稿】——顶层就有 tracks/segments; +# draft_read 的响应外面还包了一层 draft ⇒ 直接把响应存成文件会读出 tracks:0(判据失效) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"path":"/draft_content.json"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "checks": ["timeline"], + "beforeDraft": "<改前的明文草稿.json>", "afterDraft": "<改后的明文草稿.json>", + "expect": { "segmentFieldChanged": "volume" } +}' +``` + +## ★ 四条纪律(别破) + +1. **永远开副本** —— `draft_open` 的 `copy` 默认 true;`allowReal=true` 必须同时 `copy=false`(否则被拒)。 +2. **落盘后必须验证** —— `draft_commit` 会断言真草稿 sha 未变;**"没报错"不等于"生效"**, + 要用 `verify_diff` 看**语义**(哪个字段变了)。 +3. **判据用全帧最大 + 先量噪声底** —— 不用单帧抽样(单帧会假 FAIL/假 PASS); + ★ 官方渲染器**同态重渲染就有 MAD 48~123 的噪声** ⇒ 像素结论必须带 `noiseDir` 校准,否则只当参考。 +4. **★ 优先用语义判据** —— "改动生效"最硬的证据是**落盘草稿里那个字段真的变了** + (如 `volume` 0.99→0.2),它**完全确定**,而像素差不是。 + +## 常见诉求 → op + +> ★ **机器可读版**:`references/intent-map.json`(21 条"人话 → 参数"映射) +> ★ 里面的参数名由机械审计**按 schema 校验** ⇒ 拼错会被当场抓出来(不会静默失效)。 + +| 用户说 | op + params | +|---|---| +| 把这轨静音 | `track.mute` `{trackId}` | +| 把这轨删了 | `track.delete` `{trackId}` | +| 加一条视频 | `video.add` `{path}`(★ 参数名是 `path`,不是 `source`/`filePath`) | +| 加一条音频 | `audio.add` `{audioPath}`(★ 不是 `source`) | +| 加一段文字 | `text.add` `{content}`(★ 不是 `text`) | +| **加字幕(带时码)** | `text.add` `{content, startTime, duration}`(★ 时码直接吃这两个键,官方按 fps 帧对齐) | +| 这段音量调到 25% | `video.setVolume` `{segmentId, volume}` | +| 这段静音 | `video.muteOn` `{segmentId}` | +| 把这段剪成两半 | `segment.split` `{segmentId, splitTime}`(★ 不是 `splitTimeUs`) | +| 删掉这段 | `segment.delete` `{segmentId}` | +| 挪到别的位置 | `segment.move` `{segmentId, ...}`(★ 需要先有 snapshot) | +| 改这段的时间范围 | `segment.setTimeRange` `{segmentId, …}` ⚠️ **参数名尚未确证**:传 `startUs/durationUs` 官方回 `accepted` 但回读时码被清零 ⇒ 判据必须是"回读后字段真的变了" | +| 撤销上一步 | `undoRedo.undo` `{}` | +| 一次做多步 | ★ **官方没有批量 op**(实测 199 个里没有 `invokeBatch`)⇒ 连着调多次 `draft_patch`(每次 `baseRevision` 会递增) | + +> ★ **官方行为操作是带命名空间的**(`segment.move` 不是 `move`)—— 历史上"白名单 0/150"就是命名错造成的。 +> ★ **剪"中间一段"的正确链**:`split(t1)` → `split(前半, t0)` → `delete(中段)`; +> **split 会让后面的段整体后移 ⇒ 多个区间必须从后往前处理**。 + +## 失败处理 + +| 症状 | 原因 | 处理 | +|---|---|---| +| `缺少必填参数:path` | op 的必填项没给 | 查上表;参数放 `params` 里 | +| `edit/xxx 失败:segment_id_required` | 官方原样错误 | 你的 `segmentId` 不存在/已被删 ⇒ 先 `draft_read` 重新拿 | +| `edit/xxx 失败:draft_snapshot_required` | 这个 op 需要先有快照 | 先做一次别的 op,或用 `draft.setDraftId` | +| `管道连不上` | 剪映没在跑 | 启动剪映;`edit` 域必须连到活着的 VE 服务 | +| `真草稿被改动了` | ★ 危险信号 | 立刻停;查是不是误开了真草稿 | +| `verify_diff` 的 timeline 判 FAIL | 语义判据没看到预期字段变化 | ① 确认 `expect` 的字段名对不对(如 `volume`)② 确认 `beforeDraft`/`afterDraft` 是**改前/改后**两份,不是同一份 | +| `verify_diff` 的 visibility 判"不可见" | 可能是**渲染噪声**盖住了,也可能是真没生效 | 先加 `noiseDir` 校准;再用 `timeline` 维度的语义判据交叉验证 | +| `draft_read` 返回 `chars=0` | 读指定草稿时 root 没解析出来 | 传 `root`(或用 `path` 的上级目录);插件已自动补,但自定义路径时仍要核 | + +## ★ 边界(不做什么) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| 改**真**草稿 | 官方零文件监听 ⇒ 写盘不保证 GUI 立刻可见;且我们的红线是"官方库只读" | 人工在 GUI 里导入/替换 | +| 写**结构化调色**(曲线/HSL/色轮) | 草稿侧字段名尚未确证,**且官方预览渲染器本身也不认这些** | `jianying-cut` / `make_film --grade` 走我们自己的链出片 | +| 官方导出器出片 | 官方 CLI 被 −100007 / −100015 挡死,**不绕** | `jianying-cut` | +| 画布节点操作(连线/卡片) | 画布是**生成线**,与"编辑已有草稿时间线"不同轴 | 不做 | +| 从零生成(AIGC 资产/单集成片) | processor 走沙箱 agent,取不到 | 不做 | + +## 参考 + +- 官方 op 全集的实测清单:`references/official-ops.md`(18 域 199 个、逐个打桥的结果、已确证参数契约、三个"accepted 但没生效"的坑) +- 判据纪律:`references/verify-discipline.md` +- 踩坑清单:`references/pitfalls.md` diff --git a/plugins/antianqi/jianying/skills/jianying-grade-chain/SKILL.md b/plugins/antianqi/jianying/skills/jianying-grade-chain/SKILL.md new file mode 100644 index 00000000..3fd03032 --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-grade-chain/SKILL.md @@ -0,0 +1,94 @@ +--- +name: jianying-grade-chain +description: 'Use when: 查某个调色节点/参数怎么填时 / 不确定某个调色效果该归到哪个节点时 / 调色链精度口径与可选节点清单时 / 用户给了调色参数要落到链上时. 本 skill 是【库】—— 仅在其它 skill 引用它、或用户明确问调色链细节时被使用,不主动触发. 触发词: 调色链有哪些节点 / 这个参数填哪个节点 / 调色能精确到什么程度 / 曲线的控制点怎么排.' +displayNames: + zh-Hans: 剪映·调色链细节(库 · 仅被引用) +license: Apache-2.0 +compatibility: Requires nothing beyond the plugin's grade_apply verb. No external dependency. +metadata: + author: antianqi + version: "0.1.0" + basis: "10 个节点逐像素对齐官方 shader(误差 ≤1 LSB);链 API 形态见 tools/jyai-gl-bake.py 的 normalize_chain_spec" +--- + +# 剪映调色链细节(jianying-grade-chain · 库 skill) + +> **一句话定位**:回答"**这个调色效果该用哪个节点、参数怎么填、能精确到什么程度**"。 +> ★ **本 skill 是【库】**:不主动触发,只在其它 skill 引用它、或用户明确问调色链细节时使用。 +> 完整版:`references/chain-api.md`(本文件是它的入口速查)。 + +## 10 个节点(`grade_apply` 的 spec 键) + +| 节点 | 类型 | 能否烘 3D LUT | 官方对应 | +|---|---|---|---| +| `adjustColor` | 逐点 | ✅ | 基础画面调节(亮度/对比/饱和/高光/暗部/色温…) | +| `colorCorrection` | 逐点 | ✅ | 色彩校正(adjustColor 的精简变体) | +| `hsl` | 逐点 | ✅ | 分色调整(8 默认色 + 用户取色) | +| `logWheel` | 逐点 | ✅ | 对数轮(阴影/中间调/高光/偏移 四段) | +| `primaryWheel` | 逐点 | ✅ | 三原色轮 ★ **链 API 形态未接入**(参数语义未定义)⇒ 写进来直接报错 | +| `curves` | 逐点 | ✅ | 曲线(red/green/blue/luma 4 条 1D) | +| `hsl_curves` | 逐点 | ✅ | HSL 耦合曲线(6 条) | +| `sharpen` | **邻域** | ❌ | 锐化(渲染期算子) | +| `vignetting` | **uv** | ❌ | 暗角/白角(渲染期算子) | +| `particle` | **uv** | ❌ | 颗粒/光斑(渲染期算子) | + +★ **邻域 / uv 依赖的三个节点不能烘 3D LUT**(3D LUT 只能表达"逐点函数")⇒ 只能在渲染期算。 +★ `fade` / `lightSensation` 是 **`adjustColor` 的子参数**,**不是独立节点**(官方 frag 里是 adjustColor 流水线的最后两段,顺序固定)。 + +## 强度语义 + +- `-1..1` = **强度**:`0` 不动 · `+0.5` 中等偏上 · `-1` 拉满 +- `0..1` = **比例/百分比**:`sharpen.intensity` · `vignetting.param` +- 界面 0..100 ⇒ 强度 = `(v/100)*2 - 1` + +## ★ 最容易踩的坑:两条曲线的控制点布局**不同** + +| 节点 | 布局 | 判别 | +|---|---|---| +| `curves` | **A,L,R** | 第一个三元组的第二项是 **x** | +| `hsl_curves` | **L,A,R** | 第一个三元组的第二项是 **y** | + +⇒ **两套数组不能互相套用。** + +## 常见诉求 → spec + +> ★ **机器可读版**:`references/intent-map.json`(21 条"人话 → 参数"映射) +> ★ 里面的参数名由机械审计**按 schema 校验** ⇒ 拼错会被当场抓出来(不会静默失效)。 + +| 用户说 | spec | +|---|---| +| 调亮一点 | `{"adjustColor":{"brightness":0.12}}` | +| 调暖一点 | `{"adjustColor":{"temperature":0.15}}` | +| 提亮中间调、压高光 | `{"adjustColor":{"shadow":0.15,"highlight":-0.10}}` | +| 更有食欲(餐饮) | `{"adjustColor":{"saturation":0.28,"temperature":0.15,"contrast":0.12},"logWheel":{"shadow":{"r":0.14,"g":0.06,"b":0.0,"s":0.08}}}` | +| 电影感 | `{"adjustColor":{"contrast":0.22,"saturation":-0.10,"shadow":0.16},"vignetting":{"intensity":0.35,"param":0.30}}` | +| 红色更艳 | `{"hsl":{"HSLRed":{"hue":0.56,"saturation":0.62,"lightness":0.55}}}` | + +## 精度口径 + +| 路径 | 误差 | +|---|---| +| 逐点直算(`graded10.mp4`) | maxAbs **1 LSB** | +| 65³ 3D LUT(`graded_lut.mp4`) | maxAbs **2 LSB** | +| 129³ 3D LUT | maxAbs **2 LSB**(更小) | + +⇒ **要精确 ⇒ 用逐点直算那一版**;要小体积/快 ⇒ 用 LUT 版。 + +## 机器可读版 + +- 入参 schema(硬校验,**未知键直接拒绝**):`mcp/tools/grade-apply.schema.json` +- 能力字典(10 动词 / 3 类元素 / 3 类节点 / 6 类判据):`references/capability-dict.json` +- 人读版(每个参数的语义、官方对应、已知不覆盖):`references/chain-api.md` + +## ★ 边界(不做什么 / 为什么 / 替代) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| **`clear`(清晰度,8 级金字塔)接入链** | 尚未接入;且**官方默认 intensity=0 本身就是空转**(§67 实测 value=0 逐像素相同) | 只能出参考帧做对照,不能当可用节点 | +| **`colorMigration`(色彩迁移)** | 官方算法在闭源 `Amaz.Algorithm` ⇒ 拿不到 | 自算替代(与 `clear` 同类:只能自己实现) | +| **把曲线 / HSL / 色轮写回剪映预览** | 草稿侧字段名尚未确证,**且官方预览渲染器本身也不认这些**(实测:它只认强度型调色)⇒ 这条路对曲线类是关的 | 本地出片(`jianying-cut` / `make_film --grade`);★ 曲线参数由我们自己的接口给,**不需要用户在剪映里存草稿** | +| **给 `sharpen` / `vignetting` / `particle` 配 LUT 烘焙** | 这三个是**渲染期**节点(邻域 / uv 依赖)⇒ LUT 表达不了 | 链内渲染期算(已在链里) | +| **把 `fade` / `lightSensation` 当独立节点** | 它们是 **`adjustColor` 的子参数**,不是顶层 spec 键 | 写 `spec.adjustColor.fade` / `spec.adjustColor.lightSensation` | +| **用 `primaryWheel`** | 链 API 形态的**参数语义未定义** ⇒ 过去静默烘成恒等 LUT | 现在直接报错(用 `adjustColor` / `logWheel` 近似) | +| **用 `hsl` 的用户取色键(`01#ad8a86_H`)** | 链 API 形态未接入(官方要按取色器分配色相范围) | 现在直接报错 ⇒ 改用官方默认色 | +| **照抄官方 shader 的定点编码** | 官方 `enCode` 会截断(\|v\|>1 时截断)⇒ 拉普拉斯差分超 [−1,1] 会丢精度 | 链内用 float64 复刻,别在中间丢 | diff --git a/plugins/antianqi/jianying/skills/jianying-pipeline/SKILL.md b/plugins/antianqi/jianying/skills/jianying-pipeline/SKILL.md new file mode 100644 index 00000000..1ca3a5d3 --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-pipeline/SKILL.md @@ -0,0 +1,345 @@ +--- +name: jianying-pipeline +description: 'Use when: 用户要的是【一件事做完】而不是单一步骤时 / 调完色还要出成片 / 改完草稿顺便审一遍 / 剪完口播再出成片 / 全流程走一遍 / 把这条片子从头做到尾. 触发词: 一站式做完 / 全流程走一遍 / 把整条流程走完 / 改完直接出片 / 顺便也出个片.' +displayNames: + zh-Hans: 剪映·成片流水线(跨链 · 一份副本走到底) +license: Apache-2.0 +compatibility: Requires Windows + 剪映 11.6 在运行(草稿与官方渲染都走本机命名管道)+ ffmpeg 6.0 + Python 3.9+ + JYAI_HOME 指向 JYAI 仓库. ★ 剪口播那条链还需要用户自带转写稿(SRT)——官方 ASR 在 agent runtime 里、拿不到. +metadata: + author: antianqi + version: "0.1.0" + basis: "6 条链各自端到端 + 意图映射表(references/intent-map.json,21 条)+ 跨链共享会话约定(sessionId → 副本目录)" +--- + +# 剪映成片流水线(jianying-pipeline) + +> **一句话定位**:用户要的是**一件事做完**("调完色再出片"、"剪完口播再出成片")时, +> 按本手册把 2~3 条链串起来,**共用同一份草稿副本**,每一步都用**语义判据**验收。 + +> ★ **现在有两条路**:① **模型按本手册一步步调**(灵活)② **`run_pipeline` 动词**(一条命令跑完一条预置路线,路线定义在 `references/pipeline-profiles.json`)。 +> 需求只是"按路线走一遍"时用 ②;需要中途改参数/加确认点时用 ①。 + +> ★ **不确定该走哪条链时**,先要一个**判决**: +> `node ${PLUGIN_ROOT}/scripts/classify-intent.mjs "<用户那句话>" --decide` +> +> ⇒ 它给三种结果之一: +> +> | 结果 | 判据 | 你该怎么走 | +> |---|---|---| +> | **① 判决** | `decision` 非空 | **照它走**(`decision.route` 或 `decision.skill`)⇒ 依据在 `evidence`(命中词 / 分数 / 与次优的分差) | +> | **② 拒答·没线索** | `decision: null` 且最高分 < 阈值 | 按各 Skill 的 description 自己判断,或**问用户**要具体诉求(要动作、要对象) | +> | **③ 拒答·分不清** | `decision: null` 且与次优分差不足 | 把两个候选都摆给用户选,别自己拍板 | +> +> ⇒ ★ **判决是可查证的**:命中了哪些词、多少分、跟第二名拉开多少,都在输出里。 +> ⇒ ★ **判决还带出"接下来要什么"**(`routeInputs` / `mustAsk` / `params`): +> 这条路线要哪些输入 · **哪些必须问用户**(如"$srt 官方 ASR 拿不到"、"$grade 曲线形状猜不出来")· +> 参数怎么填(能查到诉求映射时直接给)。 +> ⇒ ★ **缺参会一次性点名**(开跑前预检):把缺的全部列出来,并标出哪些只能问用户要。 +> ⇒ ★ **两条纪律都有守门人**: +> 准确率由 `references/intent-testset.json` 量(top-1 ≥ 80% · top-3 ≥ 90%,低于阈值就红); +> "**绝不硬猜**"由 `references/intent-refusal-set.json` 守(18 句模糊话, +> **一句都不许被判成某条链**,否则自检变红)。 +> ⇒ ★ 判决只是**起点**:执行中发现判决与用户原话不符 ⇒ **停下来问**,不要硬跑。 +> +> ★ **本 skill 只做编排,不做实现**:每一步都落到既有动词上(`draft_*` / `grade_apply` / +> `make_film` / `render_frames` / `verify_diff` / `talk-cut-*`), +> **参数怎么填**看 `references/intent-map.json`("人话 → 参数"的机器可读版,21 条), +> **判据怎么定**看 `references/verify-discipline.md`。 + +## 什么时候用本 skill / 什么时候不用 + +| 用户要的是 | 用谁 | +|---|---| +| **一件事做完**(调完色还要出片 / 改完还要审 / 剪完还要出) | ★ **本 skill** | +| 只要调色出片 | `jianying-cut` | +| 只要改草稿 | `jianying-draft-edit` | +| 只要审片 | `jianying-review` | +| 只要剪口播 | `jianying-talk-cut` | +| 问参数怎么填 / 问验收口径 | `jianying-grade-chain` / `jianying-verify`(库) | + +⇒ **单链条的活不要绕本 skill**(多一层编排 = 多一层出错的地方)。 + +## ★ 三条纪律(别破) + +1. **先查缺什么前置** —— 缺前置就别开始,否则会做出黑屏/静音的成片。 + | 前置 | 不满足会怎样 | 怎么办 | + |---|---|---| + | **剪映在跑** | 草稿动词与官方渲染全部连不上 | 让用户先开剪映(不必开界面,但进程要在) | + | **素材在盘上** | 官方渲出黑帧 ⇒ 成片 3 秒只有 3 KB | 先确认草稿里引用的素材路径存在 | + | **口播链要 SRT** | 官方 ASR 拿不到 ⇒ 没有转写稿就剪不了 | ★ **必须让用户给 SRT**,不要自己编 | + | **调色参数已知** | 不知道"暖一点"是多少 | 从 `intent-map.json` 查;查不到就按链语义推并**问用户确认** | + +2. **一份副本走到底** —— 全流程只用**一个** `draft_open` 的副本。 + ⇒ `draft_open` 返回的 `sessionId`(给 `draft_*` 用)和 `dir`(副本目录,给 `make_film` / `render_frames` 用) + **要一起往下传**。★ 不要每一步重新 `draft_open`(那会开多份副本,状态对不上)。 + +3. **每步都要判据** —— "没报错"不等于"生效"。 + ⇒ 改草稿用**语义判据**(`verify_diff` 的 `timeline` + `expect`), + 出片用**内建验收**(`make_film` 的双流/时长/响度断言), + 画面可见才需要像素判据(且**必须带 `noiseDir` 校准**)。 + +## 意图路由表:综合诉求 → 步骤序列 + +> ★ **机器可读版**:`references/pipeline-profiles.json`(**9 条路线 / 45 个步骤**) +> ★ 每一步的动词、参数、变量都由机械审计**按 schema 校验** ⇒ 写错会当场报出来。 +> 下面的路径是它的人读展开(两边必须一致,审计 7k 会查)。 +> ★ **9 条路线**:调色并出片 / 改草稿并语义验收 / 改完直接出片 / 剪口播再出片 / 加字幕并出片 / +> 出片并重做混音 / 审这一版 / 两版对照审片 / 曲线类调色并出片。 +> ★ **10 个动词全部被路线覆盖**(`grade_apply` 是曲线类唯一走得通的路)。 + +### 路径 A:调色并出成片("把这个草稿调暖再出片") + +```bash +# ① 开副本(拿 sessionId + dir,后面两步都用它) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +# ⇒ 记下返回里的 sessionId(ve-session-N)与 dir(副本目录) + +# ② 出成片(画面来自官方渲染器;grade 由我们自己给 ⇒ 曲线类也能用) +# ★ grade 的参数从 references/intent-map.json 取("调暖一点" ⇒ adjustColor.temperature 0.15) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{ + "draft": "<①返回的 dir>", + "outDir": "<输出目录>", + "denoise": "auto", + "grade": { "adjustColor": { "temperature": 0.15 } } +}' +# ⇒ make_film 自带验收(双流齐备 + 时长对齐 ±1 帧 + 响度偏离 ≤1 LUFS)⇒ 失败会直接判失败 + +# ③(可选)画面级对比:原片 vs 成片 ⇒ 必须带 noiseDir 校准 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs render_frames '{"draft":"<①的 dir>","outDir":"<原片帧目录>"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir": "<原片帧目录>/frames", "afterDir": "<成片帧目录>/frames", + "noiseDir": "<原片帧目录>/frames-again", + "checks": ["visibility"] +}' +``` + +### 路径 B:改草稿并验收("改完顺便审一遍") + +```bash +# ① 开副本 ⇒ ② 读结构拿 segmentId ⇒ ③ 改(可多次)⇒ ④ 落盘 ⇒ ⑤ 语义验收 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"sessionId":"ve-session-N"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_patch '{"op":"video.setVolume","sessionId":"ve-session-N","params":{"segmentId":"","volume":0.25}}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_commit '{"sessionId":"ve-session-N","realDraft":"<草稿库>\\<草稿名>"}' +# ⑤ 语义判据:★ 两份草稿必须是【裸草稿】(顶层就有 tracks)——draft_read 的响应外面还包了一层 draft +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "checks": ["timeline"], + "beforeDraft": "<改前的裸草稿.json>", "afterDraft": "<改后的裸草稿.json>", + "expect": { "segmentFieldChanged": "volume" } +}' +``` + +### 路径 C:剪口播并出成片("把口播的废话剪了再出片") + +```bash +# ① ★ 先要 SRT(用户给的转写稿)——没有它就停,别自己编 +# ② 开副本 + 读结构 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"sessionId":"ve-session-N"}' +# ③ 规划保留区间(产出"保留/删除"表 ⇒ ★ 必须给用户确认后再落刀) +node ${PLUGIN_ROOT}/scripts/talk-cut-plan.mjs --srt "<用户给的.srt>" --out "" +# ④ 用户确认后落稿(走 27 个官方 op 落草稿)★ --apply 才是真剪;★ --sessionId 复用 ② 的会话 +node ${PLUGIN_ROOT}/scripts/talk-cut-apply.mjs --plan "<上一步的 plan.json>" --sessionId "ve-session-N" --apply +# ⑤ 落盘 + ⑥ 出成片(同路径 A 的 ②,draft 用 ① 的 dir) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_commit '{"sessionId":"ve-session-N","realDraft":"<草稿库>\\<草稿名>"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{"draft":"<①的 dir>","outDir":"<输出目录>","denoise":"auto"}' +``` + +### 路径 D:改完直接出片("改完直接出个片") + += **路径 B 的 ①~④** + **路径 A 的 ②**(`make_film` 的 `draft` 用同一个 `dir`)。 + +### 路径 E:加字幕并出片("给这个视频加字幕再出片") + +```bash +# ① 开副本 + 读结构 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"sessionId":"ve-session-N"}' +# ② 落字幕(★ --sessionId 复用 ① 的会话 ⇒ 不自己开、不自己落盘) +node ${PLUGIN_ROOT}/scripts/add-subtitles.mjs --srt "<用户给的.srt>" --sessionId "ve-session-N" --apply +# ⇒ 脚本自己按 ±1 帧核对时码并打印"时码吻合 N/N" ⇒ 不吻合会退出非 0 +# ③ 落盘 + ④ 出成片 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_commit '{"sessionId":"ve-session-N","realDraft":"<草稿库>\\<草稿名>"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{"draft":"<①的 dir>","outDir":"<输出目录>","denoise":"auto"}' +``` + +### 路径 F:出片并按草稿重做混音("出片时把混音按草稿重做一遍") + +```bash +# ① 开副本(混音要读草稿里的音频结构) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +# ② 先出画面(带原声),再按草稿重做混音 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{"draft":"<①的 dir>","outDir":"<输出目录>","denoise":"auto"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs audio_mix '{ + "draft":"<①的 dir>", "video":"<输出目录>/film.mp4", "out":"<输出目录>/mixed.mp4" +}' +# ⇒ 混音按草稿的音频结构重做,并归一到 −14 LUFS +``` + +> ★ 用 `run_pipeline` 跑这两条时,**上一步的成片路径不用手写**: +> 步骤上声明 `"produces": {"$film": "film.mp4"}` ⇒ 编排器把 `/film.mp4` 交给下一步。 + +### 路径 G:审这一版("审一下这版" / "交片前检查一下") + +```bash +# ① 开副本(★ 审片只读不改:只开副本做体检,不改草稿、不落盘) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_open '{"draft":"<草稿库>\\<草稿名>","copy":true}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs draft_read '{"sessionId":"ve-session-N"}' +# ② 渲两遍:第二遍只为了【量噪声底】(同一状态再渲一次 ⇒ 差异就是官方渲染器的噪声) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs render_frames '{"draft":"<①的 dir>","outDir":"<输出>/review"}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs render_frames '{"draft":"<①的 dir>","outDir":"<输出>/noise"}' +# ③ 出片(自带时长/响度验收)⇒ 成片给 ④ 量响度 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs make_film '{"draft":"<①的 dir>","outDir":"<输出目录>","denoise":"auto"}' +# ④ 单版审片(★ 没有改前 ⇒ visibility 会明说 skipped 并给出噪声底) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "afterDir":"<输出>/review/frames", "noiseDir":"<输出>/noise/frames", + "afterVideo":"<输出目录>/film.mp4", "afterDraft":"<①的 draft_read 响应里的 draft>", + "realDraft":"<草稿库>\\<草稿名>", + "checks":["visibility","picture","audio","timeline","draft_integrity"] +}' +``` + +> ★ **单版审片能判什么、不能判什么**(结果里会写清): +> +> | 维度 | 单版 | 说明 | +> |---|---|---| +> | `visibility` | **判不了** | 没有改前就没有"改动"可比 ⇒ 报 skipped,**但给出噪声底** | +> | `picture` | 只给绝对值、**不判失败** | 片子里本来就有的纯白/纯黑不算缺陷 ⇒ 绝对值不能当判据 | +> | `audio` | **能判** | 这一版的 LUFS / true peak(绝对目标 −14 LUFS) | +> | `timeline` | 只给这一版的结构 | 段数 / 轨道 / 时长;"改了什么"要改前草稿 | +> | `draft_integrity` | **能判** | 真草稿的 sha | +> +> ⇒ ★ 结果里有 **`undetermined`** 字段列出没判的维度 ⇒ **PASS 不等于"全查过了"**。 + +### 路径 H:两版对照审片("把改前改后对着看一遍") + +> ★ 这是 `jianying-review` 的**主工作流**(改前/改后对照)—— +> `run_pipeline {profile: "review-compare", draft: <改前>, draftAfter: <改后>}` 一次跑完。 + +```bash +# ① 开两份草稿的副本(★ 编排器用 setVars 把改前的会话/副本存成 $sidBefore / $dirBefore) +# —— 不存的话,第二次 draft_open 会把 $sessionId / $dir 覆盖掉 +# ② 两边各渲一遍帧 + 改后再渲一遍(噪声底) +# ③ 两边各出一支成片(音频维度要它们) +# ④ 一次 verify_diff 全维度对照: +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir":"<输出>/review/before/frames", "afterDir":"<输出>/review/after/frames", + "noiseDir":"<输出>/review/noise/frames", + "beforeVideo":"<输出>/before/film.mp4", "afterVideo":"<输出>/after/film.mp4", + "beforeDraft":"<改前的裸草稿.json>", "afterDraft":"<改后的裸草稿.json>", + "realDraft":"<改后那一版的真草稿>", + "checks":["visibility","picture","audio","timeline","draft_integrity"] +}' +``` + +> ★ 一次对照能同时回答四件事: +> **画面**变没变(带噪声底校准)· **画质**新增了多少削波/死黑 · **响度**两版各是多少 · +> **时间线**改了什么(逐段字段差)· **真草稿**有没有被误改。 + +### 路径 I:曲线类调色并出片("按这条调色链调一版再出片") + +> ★ **曲线 / HSL / 色轮只能走这条路** —— `make_film` 的 `--grade` 只能做**强度型** +> (官方预览渲染器自带的那部分);曲线类必须用我们逐像素复刻的链(`grade_apply`,10 个节点, +> 对齐官方 shader,误差 ≤1 LSB,**自带逐节点生效探针**)。 + +```bash +node ${PLUGIN_ROOT}/scripts/run-verb.mjs run_pipeline '{ + "profile": "grade-chain-then-render", + "draft": "<草稿库>\\<草稿名>", + "outDir": "<输出目录>", + "grade": { "adjustColor": { "brightness": 0.12, "contrast": 0.08 }, "logWheel": { "shadow": { "r": 0.10, "g": 0.04, "b": 0, "s": 0.05 } } } +}' +# ⇒ ① make_film 先出画面 + 混音(这一步【不做调色】) +# ⇒ ② grade_apply 用我们的逐像素链调(产物 = 10bit 成片 + LUT 版 + 前后对比图) +# ★ 曲线的写法:官方 [A,L,R] 布局(每个控制点 6 个数),且 R/G/B/Y 四条要【一起给】 +# —— 少一条会【直接报错】,不许静默烘成恒等 LUT(见 references/chain-api.md) +``` + +> ★ 编排器怎么把"上一步的产物"喂给下一步: +> `produces`(声明路径,相对 `$outDir`)· `capture`(从**响应**里取值, +> 例:调色后成片的确切文件名只有响应知道)· `setVars`(给变量起别名)。 +> ★ 报告里会带上这条路线**最终产出了什么**(`vars` 字段)⇒ 成片在哪一眼可见。 + +## ★ 两段式:人工确认点(`run_pipeline` 怎么做"先给人看,再落刀") + +> 一条路线里**必须有一步等人工点头**时(例如"保留区间表出来了,但还没落刀"), +> 把那一步标成 `"confirm": true` + `"artifact": "$产物变量"` 即可。 +> 编排器会**跑完那一步就停下来**,把产物路径 + sha + 摘要交回去;确认后带 token 续跑。 + +```bash +# ① 第一段:跑到确认点就停(verdict = AWAIT_CONFIRM,后面几步一步都没跑) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs run_pipeline '{ + "profile": "talk-cut-then-render", + "draft": "<草稿库>\\<草稿名>", + "srt": "<用户给的.srt>", + "outDir": "<输出目录>" +}' +# ⇒ 返回 { verdict:"AWAIT_CONFIRM", gate:{step, artifact, artifactSha, artifactPreview, pendingSteps}, +# resume:{token, profile, confirm:true} } + +# ② 把产物给用户看,等用户点头(这一步在编排器之外,是人的事) + +# ③ 第二段:确认后续跑(同一个 token) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs run_pipeline '{ + "profile": "talk-cut-then-render", + "resume": { "token": "<①返回的 token>", "confirm": true } +}' +# ⇒ verdict = PASS / FAIL;失败会说明"停在第几步" + +# ★ 没给 confirm 就不许跑(这是编排器的硬规则,不是口头承诺): +node ${PLUGIN_ROOT}/scripts/run-verb.mjs run_pipeline '{ + "profile": "talk-cut-then-render", "resume": { "token": "" } +}' +# ⇒ verdict = REFUSED,且关卡后面的步骤【一步都没执行】 +``` + +★ 三条纪律: + +1. **进度是持久化的**(存在 `PLUGIN_DATA/pipelines//state.json`)⇒ + MCP 调用断了、换个进程续跑,接得上。 +2. **没 `confirm:true` ⇒ REFUSED**,不是"跑一半再问你"。自检里有一条断言专门看 + "被拒绝那次,`talk-cut-apply.mjs` 根本没被执行"。 +3. **续跑时会检查产物有没有被人改过**(`artifactChangedSinceGate`)⇒ + 用户在确认前手改了产物,改了会被**点名**,不会静默吞掉。 + +## ★ 跨链共享会话(这一节是本 skill 的核心) + +| 东西 | 谁产出 | 谁消费 | 说明 | +|---|---|---|---| +| `sessionId`(`ve-session-N`) | `draft_open` | `draft_read` / `draft_patch` / `draft_commit` | 草稿改动的状态靠它串起来 | +| `dir`(副本目录) | `draft_open` | `make_film` / `render_frames` / `talk-cut-apply` | ★ **副本目录是草稿动词与出片动词之间的交接点** | +| `realDraft`(真草稿目录) | 用户给 | `draft_commit` | 只用于**落盘前的 sha 断言**(真草稿只读) | + +⇒ ★ **`make_film` / `render_frames` 不接 `sessionId`,只接草稿目录** ⇒ 跨链时要把 `dir` 显式传过去。 +⇒ ★ 真草稿**全程只读**:`draft_commit` 会逐文件 sha 断言真草稿没被改;被改了就会判失败(危险信号,立刻停)。 + +## 失败处理 + +| 症状 | 原因 | 怎么办 | +|---|---|---| +| `管道连不上` / `fetch failed` | 剪映没在跑(或刚启动、VE 还没就绪) | 让用户开剪映;**刚启动时等一下**(VE 要几十秒才起) | +| 成片只有几 KB、画面全黑 | 草稿引用的素材不在盘上 | 查素材路径;这是**输入问题**,不是链坏了 | +| `草稿读不出来(可能是官方加密封装)` | `verify_diff` 拿到的是**加密**草稿 | 传**裸草稿**(顶层有 tracks 的明文 JSON) | +| `timeline` 判 FAIL | 语义判据没看到预期字段变化 | ① `expect` 的字段名对不对 ② `beforeDraft`/`afterDraft` 是不是**改前/改后**两份 | +| `源画面几乎全黑 ⇒ 源没有可测信息` | 源本身没信息(不是调色没生效) | 换有大面积中间调/亮部的源 | +| 官方批量 op 报错 | ★ **官方没有批量 op** | 连着调多次 `draft_patch`(每次 `baseRevision` 会递增) | + +## ★ 边界(不做什么 / 为什么 / 替代) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| **改真草稿** | 官方零文件监听 ⇒ 写盘不保证 GUI 立刻可见;且官方库只读是红线 | 人工在剪映里导入/替换副本 | +| **自己编 SRT / 自己转写口播** | 官方 ASR(`AnalyzeAsset`)在 agent runtime 里、拿不到 | ★ 让用户给 SRT | +| **用官方导出器出片** | 官方 CLI 侧被 −100007 / −100015 挡死,**不绕** | `make_film`(官方渲染器出画面 + 我们的调色/混音/编码) | +| **把曲线/HSL/色轮写回剪映预览** | 官方预览渲染器本身也不认这些(只认强度型调色) | 本地出片(`make_film --grade`) | +| **发布 / 上传多平台** | 发布侧另有 skill 体系 | `daily-platforms-export` 等 | +| **从零生成(AIGC 资产 / 一条龙生成)** | 生成线在 processor 沙箱里,取不到 | 不做 | + +## 参考 + +- **参数怎么填**(人话 → 参数,机器可读 21 条):`references/intent-map.json` +- **跨链路线**(6 条路线的机器可读定义):`references/pipeline-profiles.json` +- **判据怎么定**(视野 / 噪声底 / 负向注入 / 语义优先):`references/verify-discipline.md` +- **单链条的细节**:`jianying-cut` · `jianying-draft-edit` · `jianying-review` · `jianying-talk-cut` +- **官方 op 全集**(18 域 199 个):`references/official-ops.md` +- **踩坑清单**:`references/pitfalls.md` diff --git a/plugins/antianqi/jianying/skills/jianying-review/SKILL.md b/plugins/antianqi/jianying/skills/jianying-review/SKILL.md new file mode 100644 index 00000000..7d42dfaa --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-review/SKILL.md @@ -0,0 +1,162 @@ +--- +name: jianying-review +description: 'Use when: 审片 / 检查这版剪得对不对 / 返修验收 / 这版片子整体过不过关 / 画质或响度有没有问题 / 交片前检查. 触发词: 审一下这版 / 看看剪得对不对 / 这版片子审得过吗 / 交片前检查 / 画面有没有过曝 / 响度够不够 / 时间线对不对.' +displayNames: + zh-Hans: 剪映·审片验收(多维度 · 带数字和证据边界) +license: Apache-2.0 +compatibility: Requires Windows + 剪映 11.6 在运行(官方渲染器)+ ffmpeg 6.0 + Python 3.9+ + JYAI_HOME 指向 JYAI 仓库. +metadata: + author: antianqi + version: "0.1.0" + basis: "官方 editing-review 的证据边界纪律 + 我们可运行的检测器(多维度 verify_diff)" +--- + +# 剪映审片验收(jianying-review) + +> **一句话定位**:对"改前 / 改后"两版做**多维度对照检查**,每条结论都带**数字 + 证据边界**, +> 并给出**最小返修范围**。★ 审片本身**不授权**改草稿或发布。 + +## ★ 我们和官方的差别 + +剪映官方的 `editing-review` 只有 47 行,且原文明确写着: +**「这里列出的是检查方法,不是已实现或已运行的检测器」**。 +⇒ 官方告诉你**该查什么**;**我们有真正能跑的检测器和数字**(像素差 / 画质 / 响度 / 时间线 / sha)。 + +**但纪律要跟官方一致**(这是我们从它身上学的): +- **源素材分析只能证明源素材内容;草稿结构只能证明编排事实;渲染成片的音画证据才支持"实际呈现"的结论** +- **不要把"上游成功"当成"审片通过"**(草稿应用成功 ≠ 画面对了) +- **只对工具真正覆盖的维度和范围下结论**;采样不能证明"全片没有闪帧" +- **区分四类结论**:证据支持通过 / 已确认问题 / **证据不足** / 与本次范围无关 +- **没有足够证据的必查项要保留为"待确认"**,不能因为"没发现问题"就给完整通过 + +## 检查维度(`verify_diff` 的 `checks`) + +| 维度 | 查什么 | 需要的证据 | 不达标时 | +|---|---|---|---| +| `visibility` | 改动在画面上**看得见**吗 | 改前/改后帧目录 **+ 噪声基线** | max MAD ≤ 判据线 ⇒ FAIL | +| `picture` | 画质:均值漂移 / **新增**高光削波 / **新增**暗部死黑 | 同上 | 新增削波 >2% / 死黑 >10% ⇒ FAIL | +| `audio` | 响度 LUFS / true peak | 改前/改后 mp4 | 偏离 −14 LUFS ⇒ 提示 | +| `timeline` | 段数 / 轨道 / 时长 / **逐段字段差** | 改前/改后草稿 JSON | 给了 `expect` 就按期望断言;没给且什么都没动 ⇒ **改动可能没落盘** | +| `draft_integrity` | 真草稿有没有被误改 | 真草稿目录 | 落盘时已做逐字节断言 | + +**★ 判据用增量不用绝对值**:片子里本来就有的纯白/纯黑**不算**削波/死黑,只有**被改动新推上去/压下去**的才算缺陷。 + +## ★★★ 审片前必须知道的一件事:官方渲染器**有噪声底** + +**同一份草稿渲染两次,帧就已经不一样**(实测 14~17/108 帧不同,最差 MAD **48~123**)。 +⇒ **裸 MAD 阈值(0.5)没有任何判别力**;"max MAD=123.5 ⇒ 改动可见"这类结论**不成立**。 + +**⇒ 审片必须先量噪声底**(`noiseDir` = 同一状态**再渲染一次**的帧目录): + +```bash +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir": "/frames", "afterDir": "/frames", + "noiseDir": "/frames-again", + "checks": ["visibility","picture","timeline"] +}' +``` + +> ★ `noiseDir` = **同一状态再渲染一次**的帧目录。 +> ★ **JSON 里不能写行内注释**(`#` 或 `//` 都不行)⇒ 照抄会被 `run-verb.mjs` 拒成"参数不是合法 JSON"。 +> 要注释就写在 JSON 外面(像这样)。 + +- 给了 `noiseDir` ⇒ 判据线 = `max(threshold, noiseMad × 1.5)`,输出里有 `noiseMad` / `madOverNoise`; +- **没给** ⇒ 输出里 `calibrated:false` + note ⇒ **这时"可见"只能当参考,不能当结论**。 + +**★ 结构性改动请用语义判据**(不依赖像素、完全确定): +`timeline` 维度会给出**逐段字段差**(`segmentDiffs`:段 id + 变了哪些字段 + 改前改后值), +> ★ `beforeDraft` / `afterDraft` 要指**裸草稿 JSON**(顶层就有 `tracks`)—— +> `draft_read` 的响应外面包了一层 `draft`,直接存响应会被读成 `tracks: 0`(判据静默失效)。 +配合 `expect` 直接断言: + +```bash +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "checks": ["timeline"], + "beforeDraft": "<改前的明文草稿.json>", "afterDraft": "<改后的明文草稿.json>", + "expect": { "segmentFieldChanged": "volume", "segmentsDelta": 0 } +}' +``` + +> **审片报告里必须写清"这条结论是语义的还是像素的"** —— 两者可信度差一个数量级。 + +## 主工作流 + +```bash +# ① 渲染改前 / 改后(官方渲染器) +node ${PLUGIN_ROOT}/scripts/run-verb.mjs render_frames '{"draft":"","outDir":""}' + +# ② 多维度审片 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir": "/frames", "afterDir": "/frames", + "checks": ["visibility","picture","audio","timeline","draft_integrity"], + "beforeVideo": ".mp4", "afterVideo": ".mp4", + "beforeDraft": "", "afterDraft": "", + "realDraft": "<草稿库>\\<草稿名>" +}' +``` + +**输出结构**: +```json +{ "verdict": "PASS" | "FAIL", + "checks": { "visibility": {...}, "picture": {...}, "audio": {...}, "timeline": {...}, "draft_integrity": {...} }, + "problems": ["高光削波【新增】3.21%(>2%)", ...] } +``` + +## ★★ 单版审片("审一下这版":没有改前可比) + +> 用户只给**一版**时也能审 —— 但**能判什么、不能判什么必须写清**。 +> `verify_diff` 现在支持**只给改后**(`afterDir` / `afterVideo` / `afterDraft`): + +| 维度 | 单版 | 为什么 | +|---|---|---| +| `visibility` | **判不了**(报 `skipped`) | 没有改前就没有"改动"可比 ⇒ 但**会给出噪声底**(同一状态渲两遍的差异) | +| `picture` | 只给**绝对值**、**不判失败** | 片子里本来就有的纯白/纯黑不算缺陷 ⇒ 绝对值不能当判据(要判"新增"必须有改前帧) | +| `audio` | **能判** | 这一版的 LUFS / true peak(绝对目标 −14 LUFS) | +| `timeline` | 只给这一版的**结构** | 段数 / 轨道 / 时长("改了什么"要改前草稿) | +| `draft_integrity` | **能判** | 真草稿的 sha | + +⇒ ★ 结果里有 **`undetermined`** 字段(列出没判的维度与原因)+ **`verdictNote`**: +> *"有 1 个维度【未判定】(visibility) ⇒ 这不是"完整通过";未测的维度要写成"待确认",不能写成"正常""* + +⇒ ★ **单版审片的一条现成路线**:`run_pipeline {profile: "review-only"}` +(开副本 → 渲两遍量噪声底 → 出片 → 单版审片;**只读不改**)。 + +⇒ ★ **两版对照(改前/改后)也有一条现成路线**: +`run_pipeline {profile: "review-compare", draft: <改前>, draftAfter: <改后>}` +(两份草稿各开副本 → 各渲一遍 + 改后再渲一遍量噪声底 → 各出一支成片 → 一次全维度对照; +**只读不改**)。它同时回答:**画面**变没变 · **画质**新增削波/死黑 · **响度**两版各是多少 · +**时间线**改了什么 · **真草稿**有没有被误改。 + +> ★ 审片请求不授权修改:这两条路线都只开副本做体检,不改草稿、不落盘。 + +## 结论怎么写(照官方那套分类) + +| 类别 | 什么时候用 | 写法 | +|---|---|---| +| **证据支持通过** | 该维度有检测器覆盖且达标 | "画面变化可见:max MAD 123.5 @frame74(22/108 帧有差异)" | +| **已确认问题** | 有数字支撑的缺陷 | "实际 vs 预期 → 定位 → 影响 → 最小返修 + 返修后用什么证据确认" | +| **证据不足** | 该维度没有检测器覆盖 | "响度未测(未提供 mp4)——待确认,不写成'响度正常'" | +| **与本次范围无关** | 用户没要求这个维度 | 不展开、不泛化罗列风险 | + +## ★ 最小返修原则 + +- 只修**有证据的问题**,范围最小 +- 说明**应该保持什么**(避免修 A 破坏 B) +- 给出**返修后用什么证据确认**(不是"再优化一下"这种无法执行的建议) +- **审片请求不授权修改**:用户授权修复时,才把问题与最小返修范围交给执行入口(`jianying-draft-edit`) + +## ★ 边界(不做什么 / 为什么 / 替代) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| **改草稿 / 落盘** | **审片请求不授权修改**;越权改草稿会动到用户资产 | 用户授权后,把问题与最小返修范围交给 `jianying-draft-edit` | +| **发布 / 导出成片** | 发布侧已有独立 skill 体系 | `daily-platforms-export` · `lvchuanjiao-daily-upload` | +| **"全片没有闪帧"这类全片结论** | 采样不能证明"全片没有闪帧"(官方原话)⇒ 只能写"证据不足/待确认" | 缩小范围重测,或在结论里标注覆盖范围 | +| **拿官方渲染器的裸像素差当结论** | 同态重渲染噪声底就有 MAD 48~123 ⇒ 裸 MAD 分不出"改动"与"噪声" | 语义判据(`timeline` 逐段字段差)为主 · 像素必须带 `noiseDir` 校准 | +| **结构化调色(曲线/HSL/色轮)的写回与判定** | 草稿侧字段名尚未确证 | `jianying-cut` 走我们自己的链出片 | + +## 参考 + +- 判据纪律(必读):`skills/jianying-verify/SKILL.md` · `references/verify-discipline.md` +- 踩坑清单:`references/pitfalls.md` +- 官方 `editing-review` 原文对照:`%ProgramFiles%\JianyingPro\<版本>\Resources\canvas_agent\skills\editing-review\SKILL.md` diff --git a/plugins/antianqi/jianying/skills/jianying-talk-cut/SKILL.md b/plugins/antianqi/jianying/skills/jianying-talk-cut/SKILL.md new file mode 100644 index 00000000..9bd4f3c2 --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-talk-cut/SKILL.md @@ -0,0 +1,128 @@ +--- +name: jianying-talk-cut +description: 'Use when: 剪口播 / 去掉口播里的废话、停顿、重复、卡壳 / 按转写稿整理口播 / 把长口播剪短 / 剪完落稿并回读时间线确认. 触发词: 把这段口播的废话剪掉 / 去一下口水话 / 把停顿剪了 / 有重复的剪掉 / 口播剪干净点.' +displayNames: + zh-Hans: 剪映·剪口播(去水词/停顿/重复 · 带人工确认) +license: Apache-2.0 +compatibility: Requires Windows + 剪映 11.6 在运行 + ffmpeg 6.0 + Python 3.9+ + JYAI_HOME 指向 JYAI 仓库. ★ 需要用户提供转写稿(SRT) —— 官方 ASR(AnalyzeAsset) 在 agent runtime,管道里拿不到(§74.4)。 +metadata: + author: antianqi + version: "0.1.0" + basis: "官方 studio.edit.speaker_cut 的经验层(Clean Cut 思路)+ 我们自建的 SRT 判定器与 27-op 落稿链" +--- + +# 剪映剪口播(jianying-talk-cut) + +> **一句话定位**:给一份**转写稿(SRT)**,去掉口播里的**水词 / 长停顿 / 重复**, +> 产出**保留区间表**给人确认,再把确认后的区间**真的剪进草稿**并落盘验证。 +> ★ 官方 `speaker_cut` 的经验层我们照做,**但 ASR 那一步我们拿不到** ⇒ 改用**用户提供的转写稿**。 + +## ★ 为什么用"用户提供的 SRT"而不是官方 ASR + +剪映官方的 `AnalyzeAsset`(ASR)和 `SegmentRecall`(素材召回)都在 **agent runtime** +(`vebun.exe` + `app-server.mjs`,HTTP 63178),**不在我们的 VE 管道里** +(§42.2 另已证 addon 的 526 个导出**一个都不在管道**)。 +⇒ 官方 9 个 processor 里也**没有 ASR**(只有截帧/变速/裁剪/去字幕/合片)。 +⇒ **但它缺的是"事实来源"(转写文本),不是"经验"(哪句是废话)** ⇒ 经验层我们自己做。 + +## 三级判定(保守优先) + +| 判定 | 含义 | 处理 | +|---|---|---| +| **drop** | 整条只有水词/标点(如"嗯,呃。") | **建议整句删** | +| **review** | 句中有实词但夹水词 / 长停顿 / 与前几条重复 | **标出位置,建议人工确认**(默认不删) | +| **keep** | 正常内容 | 保留 | + +★ **默认不删 review** —— 口播里的"那个/然后"有时是口语节奏的一部分,删不删要人拍板。 +★ 重复检测**往前看 3 条**(口播里"重录"常隔一句,不只相邻)。 +★ 词级水词时间按字符比例估算(粗,但够定位)。 + +## 主工作流 + +```bash +# ① 规划(纯计算,不碰草稿) +node ${PLUGIN_ROOT}/scripts/talk-cut-plan.mjs \ + --srt "<转写稿.srt>" --out "" \ + [--filler-ms 700] [--dup-sim 0.72] [--dup-window 3] [--keep-repeats] + +# ② 【人工确认】把 plan.json 里的 review 句逐条看过: +# 确认要删的 → 把该区间加进 dropRanges(或直接改 verdict 后重跑 ①) + +# ③ 应用(默认演练;加 --apply 才真改) +# ★ --sessionId :复用别人开好的会话(编排器 run_pipeline 就是这么接的) +# ⇒ 不自己 draft_open、不自己 draft_commit(落盘归编排器的 draft_commit 步骤管) +node ${PLUGIN_ROOT}/scripts/talk-cut-apply.mjs --plan "" --draft "<草稿名>" [--apply] [--sessionId ""] + +# ④ 落盘后验收:官方渲染 + 像素差 + 时间线变化 +node ${PLUGIN_ROOT}/scripts/run-verb.mjs render_frames '{"draft":"","outDir":""}' +node ${PLUGIN_ROOT}/scripts/run-verb.mjs verify_diff '{ + "beforeDir":"/frames","afterDir":"/frames", + "checks":["visibility","timeline"], + "beforeDraft":"","afterDraft":"" +}' +``` + +## ★ 怎么知道"这一刀真的剪掉了"(判据,2026-10-24 重写) + +> 官方 `segment.delete` 会**把后面的段左移补空** ⇒ "这段区间不再被覆盖"**是错的判据** +> (后面的段又盖上来了,会误判成"没剪掉")。 +> ⇒ 判据改成:**这一轨的【时长总量】正好少了 (t1 - t0) us**。 +> 与位移无关、与形态无关(改前改后都读会话快照)、多轨道也不串。 + +| | 判据 | 为什么 | +|---|---|---| +| 阳性前提 | 待剪区间在时间线上**确实被某个段完整覆盖** | 否则这一刀无从下手 ⇒ 报 SKIP,不算剪掉 | +| 生效判据 | 剪完后**这一轨的时长总量少了恰好 (t1-t0)** | 官方 accepted 不算数,得看草稿真的短了 | +| 阴性对照 | 演练模式(没 `--apply`)下判据**必然不成立** | 脚本会当场打印"阴性对照:…判据现在【不成立】" ⇒ 证明判据能区分"剪了"和"没剪" | +| 失败处理 | 判定不通过 ⇒ **退出码 4** | 上游编排器会把它当失败步骤 ⇒ 不假装成功 | + +★ 同源校验:同一个会话里读到的草稿 id 变了 ⇒ 立刻停手(我们在剪两份不同的草稿)。 +★ 实测(2026-10-24,夹具 `JYAI能力批测9a-纯视频探针`):2 个区间 +`2200000..2600000 us`(这一轨 3000000 → 2600000)与 `200000..900000 us`(2600000 → 1900000) +⇒ **2/2 验证剪掉**,真草稿 sha 不变。 + +## ★ 剪"中间一段"的正确链(实测,2026-10-21) + +```text +① segment.split {segmentId, splitTime: t1} → 切成两段,【后半段拿新 id】 + ※ split 会让【后面的段整体后移】⇒ 多个区间必须【从后往前】处理 +② segment.split {segmentId: 前半id, splitTime: t0} → 前半再切两半 +③ segment.delete {segmentId: 中间段id} → 时间线缩短 (t1 - t0) +``` +实测:时间线 9033333 → 8033333 us(正好剪掉 1 秒)✓ +**参数名(实测)**:`video.add`→`path` · `audio.add`→**`audioPath`** · `text.add`→`content` · +`segment.setTimeRange`→`segmentId`+时间 · `segment.delete`→`segmentId` · `segment.move`→`segmentId`+**需要 snapshot** + +## ★ 三条纪律(别破) + +1. **先规划、后人工确认、再改** —— 不要跳过 ② 直接 `--apply`。 +2. **动词之间共享状态**:改动在**会话内存**里,回读必须带 `sessionId` 才看得到(否则读到的是没落盘的磁盘草稿)。 +3. **落盘后必须验收** —— `verify_diff` 看**时间线维度**(段数/时长变没变)+ **可见性维度**(画面确实变了)。 + +## 失败处理 + +| 症状 | 原因 | 处理 | +|---|---|---| +| `SRT 里没解析出任何字幕条目` | 不是 SRT 格式 | 转成 `HH:MM:SS,mmm --> HH:MM:SS,mmm` | +| `找不到覆盖它的段` | 区间超出草稿时间线 | 核对 SRT 时间轴与草稿时长 | +| `找不到中间段` | split 后 id 变了 | 每步都要回读拿新 id(脚本已做) | +| `edit/xxx 失败:xxx_required` | 参数名不对 | 见上面「参数名(实测)」表 | +| `管道连不上` | 剪映没在跑 | 启动剪映 | + +## 边界(不做什么 / 为什么 / 替代) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| **自己做 ASR** | 官方 ASR 在 agent runtime(HTTP 63178),**不在我们的 VE 管道里**;本地 whisper 要装模型(可选,不在默认路径) | **用户给 SRT / 转写稿**(我们缺的是"事实来源",不是"经验") | +| **改写台词 / 生成旁白** | 官方也明确「自由改写和生成旁白走通用链路」 | 人工改写;或用 `jianying-draft-edit` 的 `text.add` 另加一段 | +| **按主题重排**(官方 topic_cut) | 需要素材召回(`SegmentRecall`)⇒ 拿不到 | 我们只做 **Clean Cut**(按 SRT 删废话) | +| **自动生成字幕文本**(ASR) | 官方 ASR 在 agent runtime,拿不到 | **用户给 SRT**;本 skill 已有 SRT 解析与逐句时间轴 | +| **落字幕(把 SRT 变成草稿里的字幕轨)** | 不属于"判断哪句是废话"的范畴 | **已能做**:`scripts/add-subtitles.mjs`(`text.add` + `startTime`/`duration` ⇒ 带时码的文字段;★ 时码按 fps 帧对齐,验收容差 ±1 帧) | +| **不加人工确认就落刀** | 判定只到 `review` 级的水词/停顿/重复**默认不删** | `talk-cut-plan` 出保留区间表 ⇒ 人工确认 ⇒ `talk-cut-apply` 才落刀 | + +## 参考 + +- 官方 `studio.edit.speaker_cut`(125 文件,Clean Cut / Topic Cut 两模式):`%ProgramFiles%\JianyingPro\<版本>\Resources\canvas_agent\skills\studio.edit.speaker_cut\SKILL.md` +- 27 个官方 op 的实测报告:`\logs\editops.md` +- 判据纪律:`skills/jianying-verify/SKILL.md` +- 踩坑清单:`references/pitfalls.md` diff --git a/plugins/antianqi/jianying/skills/jianying-verify/SKILL.md b/plugins/antianqi/jianying/skills/jianying-verify/SKILL.md new file mode 100644 index 00000000..497281a5 --- /dev/null +++ b/plugins/antianqi/jianying/skills/jianying-verify/SKILL.md @@ -0,0 +1,125 @@ +--- +name: jianying-verify +description: 'Use when: 需要一套验收判据/口径时 / 设计验收判据 / 判据视野不够要自检 / 被问"这个怎么才算通过" / 审片要给验收口径时. 本 skill 是【库】—— 仅在其它 skill 引用它、或用户明确要验收方法时被使用,不主动触发. 触发词: 怎么算通过 / 验收判据怎么定 / 这个改动该怎么证明 / 判据视野够不够 / 判据自检.' +displayNames: + zh-Hans: 剪映·验收判据(库 · 仅被引用) +license: Apache-2.0 +compatibility: Requires nothing beyond the plugin's own verbs (verify_diff / grade_apply / draft_commit). No external dependency. +metadata: + author: antianqi + version: "0.1.0" + basis: "三层判定纪律 + 十条硬规矩 + negative-injection 自检(docs/OFFICIAL-REALTIME-PATH.md §68.3/§75.2/§76.7)" +--- + +# 剪映验收判据(jianying-verify · 库 skill) + +> **一句话定位**:回答"**这个改动算不算成功、怎么证明**"——给出可执行的验收口径与判据自检方法。 +> ★ **本 skill 是【库】**:不主动触发,只在其它 skill 引用它、或用户明确要验收方法时使用。 +> 完整版:`references/verify-discipline.md`(本文件是它的入口摘要)。 + +## 核心一条:**判据的视野必须覆盖被测对象** + +**只抽一帧 = 用抽样代替了被测对象** ⇒ 假 FAIL 和假 PASS 都会出。 + +实测(2026-10-21):单帧抽样选的第 5 帧 MAD=0.47(阈值 0.5)判 FAIL, +而同一批帧里第 74 帧 MAD=123.5 / maxAbs=255 / 100% 像素变化。 +⇒ **对策:`verify_diff` 一律取全帧最大(max-over-frames)。** + +## ★★★ 核心两条(2026-10-21 §76.7 追加):**先量噪声底,再谈"可见"** + +**官方渲染器不是确定性的**:同一份草稿渲染两次,帧就已经不一样。 + +| 样本 | 不同的帧 | 最差 MAD | +|---|---|---| +| 抓帧容差 60ms | **14/108** | **48.19** @frame39 | +| 抓帧容差 20ms | **17/108** | **123.55** @frame73/74 | + +⇒ **噪声底(48~123)比任何"改动可见"阈值(0.5)高两个数量级** +⇒ **裸 MAD 不能证明"改动可见"**(历史上写下的 123.5 与噪声同量级,已作废)。 + +**⇒ 判据分三层,主判据必须落在确定的那一层:** + +| 层 | 判据 | 确定性 | 怎么用 | +|---|---|---|---| +| ① **语义** | 落盘草稿的**逐段字段差**(如 `volume` 0.99→0.2) | ✅ 完全确定 | **改草稿链的首选主判据** | +| ② 像素(自有渲染器) | `grade_apply` 逐节点探针 + 全链 MAD | ✅ 确定 | 调色链的主判据 | +| ③ 像素(官方渲染器) | `verify_diff` max-over-frames | ⚠️ 有噪声底 | **必须先校准**,否则只作参考 | + +**校准怎么用**:`verify_diff` 传 `noiseDir`(同一状态**再渲染一次**的帧), +判据线自动抬到 `max(threshold, noiseMad × 1.5)`;**不传就在输出里标 `calibrated:false`** ⇒ 不许装作可信。 + +## 三层判定 + +``` +协议层说支持 ≠ 契约层被接受 ≠ 渲染层生效 +``` + +| 层 | 判什么 | 怎么判 | 我们的工具 | +|---|---|---|---| +| **契约层** | 字段认不认 | 材料存活 / 回写明文 / 字段保留 | `draft_read`(解密回读) | +| **渲染层** | 有没有效果 | 无头取帧 + **A/B 逐像素差分** + **阳性对照** | `render_frames` + `verify_diff` | +| **机械层** | 危险动作被拦了吗 | 护栏断言 | `draft_commit`(真草稿 sha)/ `grade_apply`(逐节点探针) | + +## 十条硬规矩 + +1. **判据视野覆盖被测对象** —— 用 max-over-frames,不用单帧。 +2. **阴性对照必须存在** —— 每个"能跑"都要配一个"不能跑"的对照。 +3. **输出必须能自证是"这次跑出来的"** —— 同一输出目录**不许并发跑两份**(会互相覆盖帧、污染证据); + **基线也不许借用别的跑的残留目录**(⇒ 测的是两次不同的改动)。 +4. **探针要能区分"没生效"和"没在测"** —— **数值整齐得不正常 ⇒ 探针没在测单节点**。 +5. **参数形态不对 ⇒ 静默失效** —— 链的每个节点要么真生效、要么被整段跳过,**不会报错** ⇒ 必须逐节点探针。 +6. **注入坏了必须失败** —— 每个判据都要能被"故意弄坏"而失败。 +7. **★ 像素判据必须先量噪声底** —— 没有噪声底就不要报"改动可见";**基线必须本次自产**。 +8. **★ 阳性对照必须与被测项【同路径、同对象】** —— 只换一个已知会生效的东西;它也"没变" ⇒ 是装置坏了,换装置再测。 +9. **★ 判据的期望值必须先在【已知正确的实现】上成立** —— 判据与被测对象语义矛盾 = 自己判自己失败。 +10. **★ 噪声底必须【逐帧】量** —— 官方渲染非确定:平均 MAD 可能 0.00000 也可能 0.01602(差 1 帧、maxAbs=255)⇒ 平均值会随机翻车。 + +## 数字底线(我们复刻链的验收口径) + +| 对象 | 口径 | 实测 | +|---|---|---| +| 调色链(逐点) | maxAbs ≤ **1 LSB** | 1 LSB | +| 调色链(65³ LUT) | maxAbs ≤ **2 LSB** | 2 LSB(对齐 §54 curves 的 65³ precedent 0.0086) | +| 曲线恒等 | **字节完全一致** | 256/256 texel | +| ~~改动可见性~~ | ~~max MAD > 阈值~~ | ⚠️ **作废**(123.5 与噪声底同量级)⇒ 改用语义判据或噪声校准 | +| 真草稿 | sha 逐字节相同 | 64 files identical | +| **改草稿生效(语义)** | 逐段字段差 + `expect` 断言 | `volume` **0.99 → 0.2**(其余 3 段不变)✓ | + +## 调色的"生效"判据(三条同时成立) + +1. **逐节点探针**:每个节点单开 ⇒ MAD > 0 +2. **全链改变量**:`grade_change_mad > 0` +3. **空间分布合理**:改"亮"应该 mean 上升、**clip 比例不超阈值**(过曝是另一个问题,不是"调亮") + +## 判据自检清单(写新判据时问自己) + +- [ ] 判据的视野覆盖被测对象了吗?(不是抽一帧) +- [ ] 有阴性对照吗? +- [ ] 故意弄坏它会失败吗? +- [ ] 探针能区分"没生效"和"没在测"吗? +- [ ] 输出能自证是这次跑出来的吗? +- [ ] ★ **像素判据的噪声底量了吗?**(同一状态重渲染 ⇒ 差异有多大?没有噪声底就别报"可见") +- [ ] ★ **基线是【这次跑】产生的吗?**(借用别的跑的残留目录 ⇒ 测的是两次不同的改动) + +## ★ 边界(不做什么 / 为什么 / 替代) + +| 不做 | 为什么 | 替代 | +|---|---|---| +| **主动触发**(当任务型 skill 用) | 它是**库**:只在被别的 skill 引用、或用户明确要验收方法时用 | 由 `jianying-review` / `jianying-draft-edit` 引用 | +| **直接下"通过 / 不通过"的终局结论** | 本 skill 给的是**口径**;结论要基于被测对象与证据边界 | 调用方拿口径去跑 `verify_diff`,自己结合范围下结论 | +| **用单帧抽样当判据** | 抽样既会假 FAIL 也会假 PASS | max-over-frames(全帧最大) | +| **用裸 MAD 阈值当"改动可见"** | 官方渲染器同态重渲染噪声底 48~123 ⇒ 没有判别力 | 语义判据(逐段字段差)· 或传 `noiseDir` 校准 | +| **把不同维度合并成一个总评分** | 量纲不可比(像素 / dB / µs / 百分比) | 分维度给结论,每个维度标注证据强度 | + +## ★ 官方对照:他们给方法论,我们给能跑的检测器 + +剪映官方的 `editing-review` skill 只有 47 行,且原文明确写着: +**「这里列出的是检查方法,不是已实现或已运行的检测器」**。 +⇒ 官方是"告诉你该查什么",我们是"**已经查了,数字在这**"(像素差 / 逐节点探针 / sha 断言 / LUFS 测量)。 +⇒ 审片结论要带**数字与证据边界**,不要只给主观建议。 + +## 参考 + +- 完整纪律:`references/verify-discipline.md` +- 踩坑清单(含本项目的反面教材):`references/pitfalls.md` +- 审片 skill:`skills/jianying-review/SKILL.md` diff --git a/test/jianying.test.mjs b/test/jianying.test.mjs new file mode 100644 index 00000000..f23bae68 --- /dev/null +++ b/test/jianying.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +// The Plugin lives either under plugins/// (the hosted layout of +// the community repository) or at the repository root (a standalone Plugin +// repository that the local marketplace can install directly). Detect both so +// one test file can guard either layout. +const hostedRoot = path.join(repositoryRoot, 'plugins', 'antianqi', 'jianying'); +const pluginRoot = existsSync(path.join(hostedRoot, 'plugin.json')) ? hostedRoot : repositoryRoot; +const read = (relative) => readFile(path.join(pluginRoot, relative), 'utf8'); +const exists = async (relative) => { + try { await stat(path.join(pluginRoot, relative)); return true; } catch { return false; } +}; + +// ★ Skill 名单【动态扫描】,不硬编码 +// (硬编码过一次:新增 jianying-pipeline 时两个用例直接红了 —— +// "新增 skill 要改两处"本身就是缺陷 ⇒ 改成扫目录,此后新增 skill 不用改测试) +const skillNames = async () => { + const entries = await readdir(path.join(pluginRoot, 'skills'), { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); +}; +// ★ manifest 里的路径 "./skills//SKILL.md" ⇒ 归一到目录名(这个归一化错过两次,单独一个函数) +const skillDirFromPath = (p) => String(p) + .replace(/^\.\//, '') + .replace(/\/SKILL\.md$/, '') + .replace(/^skills\//, ''); + +const frontmatter = (text) => { + const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); + assert.ok(m, 'SKILL.md must start with a frontmatter block'); + const out = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); + if (kv) out[kv[1]] = kv[2].trim().replace(/^['"]|['"]$/g, ''); + } + return out; +}; + +test('both runtime layouts ship and agree on the Plugin identity', async () => { + const portable = JSON.parse(await read('plugin.json')); + const v04 = JSON.parse(await read('.claude-plugin/plugin.json')); + + assert.equal(portable.$schema, 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'); + assert.equal(portable.name, 'jianying'); + assert.equal(portable.license, 'Apache-2.0'); + assert.equal(v04.name, 'jianying'); + assert.equal(v04.license, 'Apache-2.0'); +}); + +test('v0.4 manifest lists every Skill at a path that resolves and whose frontmatter name matches its directory', async () => { + const v04 = JSON.parse(await read('.claude-plugin/plugin.json')); + assert.ok(Array.isArray(v04.skills), 'skills must be an array of paths'); + // ★ manifest 列的必须与磁盘上的 skill 目录一一对应(不硬编码名单) + const dirs = await skillNames(); + const listed = v04.skills.map(skillDirFromPath).sort(); + assert.deepEqual(listed, dirs, 'manifest skills[] must match the skills/ directories exactly'); + + for (const relative of v04.skills) { + // A path into a subdirectory whose name does not match the frontmatter + // name is silently dropped by the runtime: the Plugin loads, with no + // diagnostic. Assert both halves so that trap cannot reach upstream. + assert.ok(relative.startsWith('./'), `${relative} must start with "./"`); + const file = relative.replace(/^\.\//, ''); + assert.ok(await exists(file), `${relative} does not exist`); + + const dir = path.basename(path.dirname(file)); + const fm = frontmatter(await read(file)); + assert.equal(fm.name, dir, `${file}: frontmatter name must equal its directory name`); + assert.ok(fm.description, `${file}: description is required`); + assert.ok(fm.description.length <= 1024, `${file}: description is ${fm.description.length} chars (limit 1024)`); + } +}); + +test('portable layout exposes the same Skills under skills//SKILL.md', async () => { + const entries = await readdir(path.join(pluginRoot, 'skills'), { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + assert.ok(dirs.length > 0, 'there must be at least one Skill'); + for (const name of dirs) { + const fm = frontmatter(await read(path.join('skills', name, 'SKILL.md'))); + assert.equal(fm.name, name); + } +}); + +test('MCP declaration uses the stdio transport with a contained working directory', async () => { + for (const file of ['mcp.json', '.claude-plugin/plugin.json']) { + const manifest = JSON.parse(await read(file)); + const server = manifest.mcpServers?.jianying; + assert.ok(server, `${file}: mcpServers.jianying is required`); + assert.equal(server.type, 'stdio', `${file}: only stdio is used by this Plugin`); + assert.equal(server.command, 'node'); + assert.ok(Array.isArray(server.args) && server.args.length === 1); + // The compatibility contract allows a relative "./" path or a + // ${PLUGIN_ROOT}/${PLUGIN_DATA} path. Anything else breaks when the + // Plugin is installed at a different location. + assert.match(server.cwd, /^\.\/|^\$\{PLUGIN_(ROOT|DATA)\}/u, `${file}: cwd must be portable`); + assert.ok(await exists(server.args[0].replace(/^\.\//, '')), `${file}: server entry point missing`); + } +}); + +test('every verb ships a parsable input schema', async () => { + const files = (await readdir(path.join(pluginRoot, 'mcp', 'tools'))).filter((f) => f.endsWith('.schema.json')); + // 10 = draft_open/draft_read/draft_patch/draft_commit/render_frames/grade_apply/audio_mix/verify_diff/make_film/run_pipeline + assert.equal(files.length, 10, `expected 10 verb schemas, found ${files.length}`); + for (const file of files) { + const schema = JSON.parse(await read(path.join('mcp', 'tools', file))); + assert.equal(schema.inputSchema.type, 'object', `${file}: inputSchema must be an object`); + } +}); + +test('runnable script invocations in the Skills are anchored to ${PLUGIN_ROOT}', async () => { + for (const name of await skillNames()) { + const text = await read(path.join('skills', name, 'SKILL.md')); + for (const line of text.split(/\r?\n/)) { + if (!/node\s+\S*scripts\//u.test(line)) continue; + assert.match(line, /\$\{PLUGIN_ROOT\}/u, + `skills/${name}/SKILL.md: script invocation must use \${PLUGIN_ROOT}: ${line.trim()}`); + } + } +}); + +test('referenced scripts and references exist', async () => { + for (const name of await skillNames()) { + const text = await read(path.join('skills', name, 'SKILL.md')); + for (const m of text.matchAll(/\$\{PLUGIN_ROOT\}\/(scripts|references)\/([A-Za-z0-9._-]+)/gu)) { + assert.ok(await exists(`${m[1]}/${m[2]}`), `skills/${name}/SKILL.md references missing ${m[1]}/${m[2]}`); + } + } +}); + +test('the Plugin declares no network client, no credential handling and no telemetry', async () => { + for (const file of ['mcp/server.mjs', 'scripts/run-verb.mjs', 'scripts/check-env.mjs', + 'scripts/selftest-mcp.mjs', 'scripts/selftest-draft-edit.mjs', 'scripts/selftest-talk-cut.mjs', + 'scripts/talk-cut-plan.mjs', 'scripts/talk-cut-apply.mjs']) { + const text = await read(file); + assert.ok(!/\bfetch\s*\(/u.test(text), `${file}: must not make HTTP requests`); + assert.ok(!/https?:\/\//u.test(text), `${file}: must not embed a network endpoint`); + assert.ok(!/\b(api[_-]?key|secret|password|token)\b\s*[:=]\s*['"][^'"]{8,}/iu.test(text), + `${file}: must not embed a credential literal`); + } + // The only socket the Plugin opens is the local Windows named pipe of a + // running Jianying instance. A TCP target would be a network client. + const server = await read('mcp/server.mjs'); + for (const m of server.matchAll(/net\.connect\(([^)]*)\)/gu)) { + assert.ok(/PREFIX|PIPE|pipe/u.test(m[1]), `mcp/server.mjs: net.connect target must be the local named pipe: ${m[1]}`); + } +}); + +test('the Plugin does not hardcode a contributor machine path', async () => { + for (const file of ['mcp/server.mjs', 'scripts/run-verb.mjs', 'scripts/check-env.mjs', + 'scripts/selftest-mcp.mjs', 'scripts/selftest-draft-edit.mjs', 'scripts/selftest-talk-cut.mjs', + 'scripts/talk-cut-plan.mjs', 'scripts/talk-cut-apply.mjs']) { + const text = await read(file); + assert.ok(!/[A-Za-z]:\\Users\\/u.test(text), `${file}: must not hardcode a user profile path`); + assert.ok(!/D:\\/u.test(text), `${file}: must not hardcode a drive letter path`); + assert.ok(!/\/Users\/|\/home\//u.test(text), `${file}: must not hardcode a POSIX home path`); + } +}); + +test('the README discloses the platform, the example prompt, the expected result and the data behaviour', async () => { + const readme = await read('README.md'); + assert.match(readme, /Windows/u, 'supported platform must be disclosed'); + assert.match(readme, /示例提示词/u, 'README must carry a copyable example prompt'); + assert.match(readme, /预期结果/u, 'README must state the expected result'); + assert.match(readme, /无网络访问/u, 'README must disclose network behaviour'); + assert.match(readme, /不需要凭据/u, 'README must disclose credential behaviour'); + assert.match(readme, /无遥测/u, 'README must disclose telemetry behaviour'); + assert.match(readme, /无第三方服务/u, 'README must disclose third-party service behaviour'); + assert.match(readme, /不需要任何账号/u, 'README must disclose account and paid-service requirements'); +}); + + +// ★★★ 会话状态不得越界(PR #72 review 的合并阻塞项:sessionId 路径穿越) +// server.mjs 原来把 sessionId 原样拼进状态文件名 ⇒ "x/../../canary" 经 path.join +// 归一化后落到 PLUGIN_DATA 之外 ⇒ draft_read 读出任意本地 JSON、draft_patch 往那里写。 +// 这里跑插件自带的探针:9 种恶意 sessionId × 3 个动词 + 写路径检查 + 阳性对照。 +test('session state cannot read or write outside PLUGIN_DATA', async () => { + const mod = await import(pathToFileURL(path.join(pluginRoot, 'scripts', 'security-probe.mjs')).href); + const r = await mod.runSecurityProbe(); + const failed = r.cases.filter((c) => !c.ok); + assert.equal(failed.length, 0, + 'session-state containment failures:\n' + + failed.map((f) => ` - ${f.name}: ${f.detail}`).join('\n')); + // ★ 探针本身要有区分力:必须覆盖足够的形态(9 种恶意 × 3 动词 + 写路径 + 阳性对照) + assert.ok(r.total >= 30, `expected at least 30 containment checks, got ${r.total}`); +});