# 摄鲸插件 · 设计说明 这份文档记录**为什么这么做**,而不是做了什么。每一条背后要么有一次真实事故, 要么有一次明确的取舍。改代码之前先读这里。 ## 1. 定位 摄鲸(shejing)是一套照片工作流:**体检 → 剔除 → 整理 → 调色 → 验收 → 归档 → 复盘**。 它原本是一个纯 skill:模型靠提示词记住流程,用 bash 调 Python 脚本,再委托给 `lightroom-classic` 去碰 Lightroom。那个形态有三个绕不过去的问题: 1. **流程不可强制。** 最硬的一条规则「任何新参数组合必须先单张渲染验证再批量」 只写在提示词里。它被违反过一次,代价是 25 张照片整批染成紫红,诊断回滚花了十几轮。 2. **跨会话失忆。** 规则、坑、参数表散在 markdown 里,换了会话就要重新建立上下文。 3. **装不到别人机器上。** 脚本硬编码了使用者自己的路径。 所以插件要解决的不是「更方便」,而是**让那次紫色事故在机制上不可能重演**,并且 能装到任何人机器上。 ## 2. 七阶段 → 七个工具 | 工具 | 角色 | 门禁 | |---|---|---| | `shejing_checkup` | 体检:连拍分组、组内清晰度、曝光跨度、类型判定、溢出统计、contact sheet | —(只读) | | `shejing_cull` | 剔除:源文件夹内建 `可导入/` 与 `非导入/`,同盘移动 | 标记/移动门禁 | | `shejing_organize` | 整理:模板重命名 + 原地导入 + 建收藏夹 | 不可逆门禁 | | `shejing_grade` | 调色:内置风格或显式参数,逐张建快照并渲染 | **新参数门禁** | | `shejing_verify` | 验收:抽样渲染 + 目录数据库交叉核对 | —(只读) | | `shejing_archive` | 归档:精选计划 + 导出 + 批次总结 | 导出确认 | | `shejing_retro` | 复盘:教训追加进拍摄前规则 | 写入确认 | | `shejing_doctor` | 自检:链路、token、端口、Lua 同步状态 | — | | `shejing_batch_status` | 读账本 | — | **分工是刻意划的**:Python 脚本负责算账(数张数、分组、曝光跨度、感知哈希、 contact sheet、文件移动),这些是纯计算而且已经在真实批次上验证过;插件负责编排、 参数翻译、账本与门禁。**重写已验证的逻辑是纯粹的回归风险,不带来任何收益。** ## 3. LR 通道:为什么自托管 MCP 客户端 原本最自然的做法是声明式地配一行 `@deepseek-ai/dsh-mcp-client`,让 DSH 自己拉起 桥接。做不到,原因是两个硬约束叠在一起: 1. `mcp-client` 是**纯配置驱动**的,一行一个 server,**没有运行时注册 API**, `command` 是直接交给 `child_process.spawn` 的字符串。 2. 发布到 npm 的包**无法知道**用户机器上 pnpm 把包装进了哪个 profile 的 `node_modules`,所以那个字符串里写不出 bridge 的绝对路径。 `command: 'node'` 也不可靠——GUI 应用的 PATH 极简。 于是改成宿主半边自己用 `import.meta.url` 定位 bridge、用官方 MCP SDK 的 client 端 拉起它并说协议,再把 56 个工具注册成 `mcp__lightroom__*`。 **附带的好处比妥协更大**:工具边界落在我们手里,所以调色的硬门禁可以统一收口 (见 §5);也不必赌「我们的行先于 mcp-client 行激活」这种顺序假设。 ## 4. 内置的 LR 通道是 fork,不是原创 `lrbridge/` 是 `@pired/lightroom-mcp`(上游 `Automaat/lightroom-mcp`,MIT)的 可分发副本,含两处针对本机的修改: | 文件 | 修改 | 原因 | |---|---|---| | `HandlerOrganization.lua` | 色标名改查表 | 中文界面下色标集叫「绿色」而非 `Green`,原实现恒报 `0 photos (N mismatched)` | | `HandlerCollections.lua` | `create_collection` 真正使用 `parent` | 原实现声明了该参数却在写入时丢弃 | 上游的 `.lrplugin` 安装器有个陷阱:**已存在就跳过**(`isPluginInstalledAnywhere`)。 这意味着用户装过上游版本、或者插件更新了 Lua,新代码都不会生效。所以 `src/lr/install.mjs` 自己做**内容比对 + 备份 + 覆盖**,并且**永不删除**目标目录里 我们不认识的文件(用户自己的 `.bak` / `.orig-dsh` 要留着)。 ## 5. 门禁:唯一不可绕过的部分 ### 四类门禁 1. **新参数门禁** —— 任何新参数组合首次应用前必须单张先行(最硬的一条) 2. **标记/移动门禁** —— 任何色标、旗标、文件移动 3. **不可逆门禁** —— 改硬盘文件名、移动文件 4. **堆栈确认** —— 疑似焦点堆栈单独开一回合 ### 为什么落在 `tools/pre-execute` 上 钩子返回 `{ kind: 'ask', reason }` 会被 **DSH 原生路由给用户**,用户拒绝时注册表 自己 deny。模型无法伪造这个答复——这是它比「让模型去调 `ask_user_question` 再带 token 回来」硬得多的原因。 ### 判定粒度 - 只拦**批量**(照片数 ≥ 2)。单张调用正是「先行」那一步本身,不该被拦。 - 只拦会写**调色参数**的工具。`set_rating` / `set_color_label` / `set_flags` 这类 可逆标记不属于参数门禁,规则不同。 - 指纹只算**参数**(不含照片 id),所以「同一套参数第二次不再拦」成立。 ### 一条差点留下的旁路 `shejing_grade` **内部直接调桥接**,不走 DSH 工具——所以 `pre-execute` 钩子本来 根本看不见它。钩子因此专门认这个工具名。同理,客户端面板触发的动作也必须回到 同一个门禁引擎,不能自成一套。 ### 不可越权 没有 `force` 旁路。参数通过一次之后进白名单(`$SHEJING_HOME/gates/approved-params.json`), 第二次同样的参数不再拦——所以正常效率不受影响。 `shejing_grade` 在单张执行时会记一笔「这一套参数已在单张上渲染过」,门禁的理由里 会**如实**引用它:有就报出预览路径,没有就明说「⚠️ 没有单张渲染记录」。 ## 6. 数据与账本 账本放 `$DSH_HOME/shejing/batches/<批次>/`,**不放在照片文件夹里**: 插件升级/重装不丢账;照片目录保持干净(只多出 `可导入/` 与 `非导入/`)。 批次身份沿用用户习惯:**片子在哪个文件夹,那个文件夹就是一批**,插件不要求改目录习惯。 `非导入/` 永久保留,删除动作只能用户来做。**只标记不删**是设计的底线。 ## 7. 三种内置风格是数据,不是文档 风格原先是 markdown 表格,靠模型照着抄滑杆值和曲线点。抄错就是又一次事故。 现在它是 `src/styles.mjs` 里的结构化数据,指纹可以直接算在它上面,门禁因此拦得住。 **顺带发现的一个真实陷阱**:风格 B 的主曲线原表首点是 `[0,8]`(红 `[0,2]`、蓝 `[0,4]`),而 `normalizeCurvePoints` 在首点不是 `(0,0)` 时会**前插** `(0,0)`, 写出重复的 x=0 点——这正是那次紫红事故的触发条件。`expandStyle()` 现在自动把这类 首点归零为 `(0,0)`(与「丢掉首点让工具自己补」等价,但保留锚点数量),并把改动 记进 notes。 ## 8. 体检的曝光数据不依赖 Spotlight `10_checkup.py` 原先用 `mdls` 取曝光/光圈/ISO。那要求文件已被 Spotlight 索引—— 点号目录、外置盘、网络盘、刚拷贝的文件都会**静默返回 `(null)`**。 后果不是「少几个数字」:曝光跨度一旦为空,**包围曝光与连拍就分不开**,而按摄鲸的 规则这两类处置完全相反(包围曝光一律全留,连拍可进剔除建议)。 现在主路径是 `python/exif.py`——纯 Python 解析 TIFF/EXIF,ARW/NEF/CR2/DNG 通吃, JPEG 走 APP1。`mdls` 只在缺关键字段时才兜底。时间戳也改用 EXIF `DateTimeOriginal`, 比文件系统时间更正确。 ## 9. 开发环境 `desktop` profile 由 Electron 应用独占管理,GUI 插件管理器只接受**注册表上的包名** (拒绝 `file:` 与本地路径)。所以本地开发版装不进桌面端。 替代方案:从 `app.asar` 抽出与桌面端**版本完全一致**的运行时,配一个独立的 `DSH_HOME` 跑 web 端。1.2 秒抽完 343 MB,完全不碰用户现有的任何 profile。 产品侧的现实是:**桌面端验证必须先发一次 npm 包。** ### 9.1 一个曾经写错的判断:桌面端到底有没有 webserver 早先的调研结论是「桌面 composition 禁用了 `webserver`,所以 `ctx.webServer` 与挂在 它上面的 `/api` 路由在桌面端都是死代码」。**这个结论对已安装的 0.1.7 不成立。** 它来自 0.1.5 源码里的 `apps/desktop-host/config/desktop.cordis.patch.yml`——但 0.1.7 的 Electron 是完全不同的实现。三条证据推翻了它: 1. `desktop-runtime.json` 的包清单里有 `@deepseek-ai/dsh-host-webserver@0.1.7-rc.2`; 2. `@deepseek-ai/dsh-desktop-host` 的 `dependencies` 里**直接依赖**它; 3. 实测桌面端进程对 `/api/definitely-not-a-route` 返回 **401 而不是 404**—— 而那个 401 正是 `/api` 前缀路由里的 `connection.admit()` 给出的,说明路由挂着。 **教训**:源码 checkout 与已安装产物是两个东西。跨版本推断必须落到实测或产物本身。 我们仍然用 `ctx.connection.fetch.register` 而不是直接注册到 `webServer`——但理由换成了 「传输无关 + 不绕开连接层的鉴权围栏」,而不是原来那个错误的「桌面端没有 webServer」。 ### 9.2 桌面端与我们的隔离实例是同构的 `~/.dsh/profiles/desktop/package.json` 的 `dsh.profile.bundles` 是: ```json ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] ``` 而我们的隔离实例(用来跑端到端验证的那个)是**同样两个 bundle,只多一个 `dsh-shejing`**。 桌面端 Electron 外壳加载的正是同一套 `dsh-web-app`,客户端模块表(`dsh-client-modules`) 由它提供——也就是说,桌面端加载第三方客户端插件的机制**就是我们已经验证通过的那一条**。 仍然没被证明的只有一件事:Electron 外壳本身会不会在加载我们的浏览器半边时出问题。 那需要真的装一次——所以**先发一个预发布版本到 npm,用 GUI 装上确认,再发正式版**。 ## 10. 端到端回归抓到的两个真 bug `scripts/regression.mjs` 用一份**克隆出来的真实 75 张批次**驱动真实工具实现。 它第一次跑就抓到两个都不报错、不崩溃、只是**静默做错事**的 bug: ### 10.1 待剔名单传了 28 个名字,只剔掉 1 张 `20_split.py` 的 `--reject` 用 `nargs="*"` 而没加 `action="extend"`。 argparse 对重复出现的 flag **只保留最后一次**: ```python # --reject a --reject b --reject c → ['c'] ``` 而插件是逐个名字重复传 `--reject` 的。于是「传了 28 个名字」变成「只剔掉最后一个」, `可导入/` 里多了 27 张本该剔掉的照片,**没有任何报错**。 修法:脚本侧 `action="extend"`(两种传参风格都累积),插件侧改成一个 flag 带全部名字。 ### 10.2 分组不是划分 `group_bursts` 的内层循环把一帧加进组时,**没检查它是否已被前一组占用**: ```python if b["hash"] is not None and hamming(a, b) <= hash_max: grp.append(b) # ← 不检查 j 是否已在 used 里 used.add(j) ``` 一个「被前一组的种子判为不同、但和这一帧相同」的帧会开一个新组,再把已属于前一组的帧 **再抓一次**。实测 14 个组共 42 个帧引用、只有 41 个唯一帧。 危害不是计数不准,而是**建议自相矛盾**:同一帧可能在某组是「建议保留」、 在另一组是「建议剔除」。而且它和 10.1 叠在一起,让「少剔一张」变得极难察觉。 修法:种子与成员都必须未占用。算法抽成 `group_bursts()` 以便单测,并加了 「分组必须是划分」的不变量断言 + 200 轮随机压力测试(`tests/test_grouping.py`)。 ### 10.3 没有渲染成功,却建议用户去看渲染图 `shejing_grade` 在**一张都没渲染成功**时(Lightroom 没开、桥接起不来), ② 建议那一栏仍然写着: > 这是单张先行。请把渲染图交给用户看,他认可后再对这批照片批量调用本工具。 而 ③ 预览图那一栏是空的、下面还有一行失败说明。模型很可能照着「建议」去让用户看 一张不存在的图,或者干脆编一个渲染结果出来——这是最容易骗过人的一类缺陷, 因为**工具没有报错、报告格式完全正常**。 修法:`previews.length === 0` 时改说「一张都没有渲染成功,所以现在**没有任何图可以 给用户看**……不要向用户描述渲染结果」。回归测试第 ⑧ 步专门断言 「没有渲染成功时不该出现『请把渲染图交给用户看』」。 ### 10.4 用户设的环境变量被静默丢弃 写 LR 通道测试时发现的:MCP SDK 的 `StdioClientTransport` 只继承一份「安全」清单 (POSIX 上是 `HOME` / `LOGNAME` / `PATH` / `SHELL` / `TERM` / `USER`),其余一律 不传。所以用户在 DSH 环境里设的 `LIGHTROOM_MCP_REQUEST_PORT`、`LIGHTROOM_MCP_TOKEN_PATH` 之类**根本到不了 bridge 子进程**——「明明设了端口却不起作用」是最难查的一类怪事。 修法:显式转发 `LIGHTROOM_MCP_*` 与 `SHEJING_*`。(SDK 是**合并**语义: `{...default, ...env}`,所以不必自己补 HOME。) 顺带让 bridge 入口可以用 `SHEJING_BRIDGE_ENTRY` 覆盖——测试需要指向假桥接, 用户想改用上游的 `@pired/lightroom-mcp` 时也不必改包。 ### 10.5 教训 这四个 bug 都通过了「跑得通、没报错」这一关: | bug | 表面现象 | 实际后果 | |---|---|---| | `--reject` 只保留最后一个 | 剔除「成功」 | 27 张该剔的留在了 `可导入/` | | 分组不是划分 | 报告正常 | 同一帧的建议自相矛盾 | | 空预览却说「请用户看图」 | 报告正常 | 模型描述一个不存在的结果 | | 环境变量被丢弃 | 一切正常 | 用户的覆盖设置无声失效 | **只有拿真实数据端到端跑一遍,并且断言可验证的不变量**(文件数守恒、分组无重叠、 「没有图就不许叫人看图」),才能抓到它们。 回归现在覆盖八个步骤:体检 → 剔除预演 → 真剔除 → 验收 → 归档计划 → 整理重命名(预演/真改)→ 复盘(预演/真写)→ Lightroom 不可用时的降级。 其中**只有「归档的实际导出」与「调色的实际写入」两步必须依赖 Lightroom**, 其余全部可在无 Lightroom 的环境下验证。 ## 11. 性能:每张照片只解码一次 `10_checkup.py` 原先每张照片调两次 `sips`(一次 300px 缩略图、一次 1200px 大图)。 实测**单次 `sips` 解码一张 ARW 约 7 秒**,于是每张 14 秒:75 张要 17 分钟, 500 张要两小时以上。 而 Pillow 从 1200px 缩到 300px 只要 **0.05 秒**。改成每张只调一次 `sips` 解码到 1200px,缩略图用 Pillow 派生(失败时回退 `sips`)。 顺带把**感知哈希改成从大图算**:哈希决定分组,不该随缩略图的生成方式变化—— 换一个重采样核就可能让分组漂移,那是不必要的脆弱。 ## 12. 明确没做的事 - **Windows**:代码留了跨平台余地(`process.platform` 分支、不硬编码路径),但没验证。 - **面板界面双语**:v1 只有中文。槽位系统自带 locale 机制,以后加不用重构。 - **不用 `import_photos` 的 `copy_to`**:上游 `HandlerImport.lua` 根本没实现这个参数, 「不传 `copy_to` 才是原地引用」这句文档是**对的结论、错的原因**。 - **不直写 `.lrcat`**:Lightroom 开着时直写必然损坏目录。只读副本可以随便查。