# 需求:Loom-像素后处理算子族：对标-FrameRonin-复刻抽帧-抠图-切片-合成-导出五段管线

状态: done(2026-10-14 已落地:P0-P3 全清:像素族 14 算子(冻结集 21+14=35)+3 条 sprite recipe+loom_ui_ir frame_strip+loom-web /sprite 面板;验收 9 项逐条带口径勾完(md5 双跑相同/对拍 0.00px/Godot 4.4.1 真加载/L5022 反向注入阻断/合规 grep 零命中);遗留:23 个 Gemini Gem 对照样本未入库、ops/nodes.json 无 spindle 节点待拍板、混链等 GPU 窗口)
归属: pinnadash 内部产品（spindle Loom 织机）
来源: 调研(docs/竞品分析/2026-10-14-FrameRonin功能拆解与Loom像素管线缺口对标.md)
日期: 2026-10-14
建议: 做,Loom 有 DSL/编译器/确定性 UI 却只有 21 个 ComfyUI 生图算子，像素后处理族整族缺失；素材工坊已有一批同类 Python 算法但前端仅 261 行、不可编排不可回放——收进 Loom 算子+recipe 正好补上立身之本的空白，且是 FrameRonin 用户已在要的「自定义流程蓝图」

## 现状

**一句话：Loom 有织机没有线——DSL/编译器/确定性 UI 齐了，可编排的算子只有生图一族。**

- Loom 侧（`D:/code/work/lin/spindle`，MVP-2 进行中）：`registry/ops/` 共 **21 个算子，全部是 ComfyUI 生图族**（`k_sampler`/`vae_*`/`lora_*`/`controlnet_*`/`clip_*`/`image_scale`/`image_invert`/`image_upscale_model`/`load_image`/`load_mask`/`save_image`/`instruct_edit`/`empty_latent`/`latent_upscale`/`checkpoint_loader`/`upscaler_loader`/`clip_set_last_layer`）；`registry/recipes/` 8 条（txt2img/inpaint/outpaint/style_transfer/person_swap/ref_compose/upscale/oil_painting）。**像素后处理族 0 个算子、0 条 recipe**——抽帧、抠图、切片、归一、合成、导出全都没有。
- 素材工坊侧（`D:/code/work/lin/game_material_workshop`）：同类算法**其实已经写过一份**——`sprite_alignment.py`（脚底基线锁定 + 根锚点归一 + 方向公共缩放估计，`GROUND_LOCKED_ACTIONS = {idle,walk,run,attack,die,hurt}`）、`sprite_composer.py`、`background_remover.py`、`pixelate_processor.py`、`video_sprite_pipeline.py`（ark_video + 相位对齐抽帧 + 动作差分门 + 跨方向集评估）、`quality_gate.py`、`engine_neutral_pack.py`；文档有《工业级像素素材提示词手册》88 行与像素素材包 MVP 清单 P0/P1/P2。**但 `apps/workshop-web/src` 只有 261 行**（App + 任务轮询），链路是 FastAPI 里的硬编码顺序调用，**不可编排、不可确定性回放、无算子契约**。
- 对标对象侧：FrameRonin 开源 V3 有 **34,855 行组件+算法库、15 个能力族**（拆解与行数见来源文档 §3），全部客户端可跑；他闭源的部分只有「生图壳 + 18 个 preset 提示词 + 代充计费」，**恰好是最便宜的一层**（开源版 `api.ts` 仅 108 行且不含任何模型调用）。
- 因此本票的判据不是「从零复刻 40 组件」，而是**把已有算法升格成 Loom 算子契约 + recipe，并补齐真正缺的那几件**（散点切片、透明间隙分割、逐帧平移裁边、接缝验证、Godot SpriteFrames 出口）。
- ⚠️ 口径冲突先摆出来：boss 说「在 spindle 发起开发」，但像素算法的现存实现在素材工坊。本票按 **「Loom = 算子与编排正本，素材工坊 = 算法供体（只读对齐，不改其代码）」** 写方案；若要把算法抽成 `pixel_kit` 共享件供两仓共用，**另开一票**，不在本票范围内。

### 待拍决策点

- **D1 算法落点**：(a) 直接在 `loom-runtime` 内实现 Python 算子，素材工坊暂不动（推荐，最直白、零新间接层）；(b) 先抽 `pixel_kit` 共享件包再两边接（多一层发包/版本化成本，且素材工坊现无消费诉求）。
- **D2 交互边界**：FrameRonin 的画笔/橡皮/逐帧手动平移（`ImageFineEditor` 989 行、`Rseprite/*` 1066 行）属**编辑器**，与 Loom「确定性 UI、只露简单参数」的口径相冲。本票按**判为后置、不进 P0–P3**处理（推荐），只做参数化预览与勾选。
- **D3 是否花 100 元买代充**换他 18 个 preset 的出图样本做反推对照（可选，不阻塞开发；不批则提示词只走我方手册自扩）。

## 方案

分四期，每期独立可验收；全部落在 `D:/code/work/lin/spindle`，本地端口沿用已登记的 3061（`ops/ports.json`，不新分配）。

**P0 算子族骨架 + 最小闭环（先让「一条序列帧管线」能被 DSL 表达）**
1. `registry/ops/` 新增 3 个算子：`video_extract_frames`（FFmpeg 按目标 fps/区间/最大帧数抽帧）、`matte_chroma`（绿幕/蓝幕/白底 + 双背景去背 + 抑色容差）、`sheet_compose`（固定列数/自适应方形布局合成 RGBA 大图 + index.json）。
2. 每个算子按 `docs/INIT-PLAN.md`「算子族中立」写契约：入参 schema 进 `loom-schema`，错误码续接现有 L5xxx 段。
3. 新增 recipe `registry/recipes/sprite_from_video.toml`，`loom run` 端到端出 `sprite.png` + `index.json`。

**P1 切片与归一族（FrameRonin 真正的门槛所在）**
4. `slice_grid_nxm`（N×M 均分，**「切块网格」与「预览排列」分离**——改 N/M 重切原图、改预览列数只重排，这条是他 `SpriteSheetAdjust` 2538 行的核心心智）；`slice_transparent_gap`（按透明间隙/连通域分割，容差可调）；`slice_scatter`（散点切片）；`unify_size`（统一尺寸 + 居中/对齐 + 边距间距）。
5. `frame_normalize`：主体 bbox（`tight_bbox`/`safe_bbox`）→ 硬缩放 → **脚底基线锁定 + 根锚点归一**，口径逐项对齐素材工坊 `sprite_alignment.py`，并做数值对拍测试（同输入两仓输出比对）。
6. `frame_transform`：逐帧平移 + 四边裁边（参数化，不含画笔）。

**P2 出口与质量门**
7. `export_godot_spriteframes`：产出 Godot 4.x `.tres`/JSON（`frameronin.godot-spriteframes.v1` 是竞品用的键名，我方自定 `loom.godot-spriteframes/v1`）；同时保留 `mapforge-map/v1` 与 `gm-asset-standard/v1` 两个既有出口对齐评估（瓦片图集出口归素材工坊/MapForge，不在本票内实现）。
8. `seam_preview_check`：3×3 平铺接缝自检（对应他 `RoninProL7SeamPreview`），输出接缝得分，接进 recipe 的质量门。
9. `quality_gate`：动作差分门 + 跨方向一致性门，阈值口径与素材工坊 `video_sprite_quality.py` 对齐后自实现。

**P3 recipe 与确定性 UI**
10. 新增 recipe：`sprite_from_sheet`（整表拆帧→重排→再合成）、`char_actions_4dir`（锚图 → 四方向 × N 帧，内化提示词只露「角色参考图 + 动作集 + 尺寸」）。
11. `loom-ui-ir` 出对应面板：参数表单 + 分帧预览（勾选帧、动画播放、A/D 切帧）+ 出图三件套复用现有画廊；**不做画布编辑器**（D2）。
12. 提示词：以我方 `docs/INDUSTRIAL_PIXEL_PROMPT_PLAYBOOK.md` 为骨架扩写并内嵌进 recipe 的 prompt 段；FrameRonin 的 23 个公开 Gemini Gem 仅作**对照学习样本**入库 `docs/竞品分析/`，不搬运原文。

**P3 落地状态（2026-10-14）**：第 10、11 项完成——新增 `LoadSheet`/`CollectFrames` 两算子（像素族累计 14）与 `sprite_from_sheet`、`char_actions_4dir` 两条 recipe；`loom_ui_ir` 出 `preview.type=="frame_strip"` 契约，loom-web 新增左侧导航「序列帧」面板（表单由 recipe 数据驱动、分帧预览支持勾选帧/按 fps 播放/A·D 与方向键逐帧、导出 `.tres` 并显示引擎帧目录、产物与 sidecar 进同一画廊），**未做画布编辑器**（D2 后置照旧）。含生图支路的 `char_actions_4dir` 在 `/api/sprite` 明确 `L5003` 拒绝，等生图后端与 `graph_exec` 合流。
第 12 项**部分完成**：prompt 段已按我方《工业级像素素材提示词手册》「单帧方向模板」骨架扩写并内嵌（`spindle/registry/recipes/builders.py:DIRECTION_TEMPLATE` + 配套 negative 清单：无相机移动/缩放/裁切/分隔线/邻帧/身份漂移/烘焙阴影/抗锯齿）；**23 个公开 Gemini Gem 的对照学习样本尚未入库**——需登录外部站点逐个抓取，与本期管线代码无耦合，不阻塞验收，留待单独排期。

**收尾补记（2026-10-14 深夜，同一票内）**：复盘发现「复刻管线」当时只做到**组件复刻**——14 个像素算子里有 6 个（间隙切片、散点切片、统一尺寸、逐帧变换、接缝自检、Godot 导出）实现与单测齐全却没有任何 recipe 编排得到，用户在面板上走不到。现已全部接进编排：`sprite_from_sheet` 增 11 个参数开关（切片方式 grid/gaps/scatter、逐帧平移裁边、是否导出 `.tres`），新接第 4 条链 `tile_from_image`（接缝自检→切块→统一尺寸→图集，对标竞品的瓦片/平铺路，默认构图逐字节不变），并加闸 `test_every_pixel_op_is_reachable_from_a_recipe`。支路实跑常驻 `scripts/pixel_branch_audit.py`（6 判决：3 过 / 2 拦 / 1 拒，拦与拒都**不落产物**；瓦片同参连跑 md5 相同）。接线过程抓出两个真问题：① `MatteChroma` auto 档对**自带透明背景**的紧裁帧会按边框中位数猜成角色色，把角色抠成空洞（实测身高 32px→44px），改为直通原 alpha；② `/api/sprite` 的输入文件白名单是手抄参数名，新链的 `image` 参数没进名单 → 路径校验被绕过，改为 recipe 声明 `file_input` 并由服务端/前端同源读取、缺声明即拒收。取证与判据见 `spindle/docs/worklog/2026-10-14-pixel-sprite-p0-p2.md` §九/§十。

**第二段补记（2026-10-14 更晚，同一票内，对标族再往前推三段）**：① **F 族**新增 `ExportAnimatedGif`（保透明动图，逐帧核前景像素数）与 `FrameRepeat`（each/pingpong，**排在质量门之后**——复制帧一模一样，会被「相邻帧最小变化」正确判红，顺序有用例钉住）；② **C 族**新增 `Pixelate`（限色调色板 + 半透明边硬化，作表链可选前置，**只改像素不改几何**），与素材工坊 `pixelate_processor` 走 `scripts/pixelate_parity.py` **逐像素全等对拍**（20 组样本 max diff **0**，不是「≤2px」；判据可证伪性当场验过——初版整块矩形样本注入 `>0`→`>64` 的口径漂移仍全等，重造 1px 斜线 + 棋盘点样本后同一注入 3 组判红）；③ **A 族**的 `sprite_from_video` 从三段升到**五段全链**（抽帧→去背→归一→质量门→合成，与表链同一套门禁参数），此前「归一 + 质量门只有表路走得到」是两条路交付口径不一致，真视频实证 8 帧 → `.tres` + `anim.gif`，审计脚本累计 **11 判决全 OK、exit 0**。像素族累计 **17**、全库 **38**。仍未做：光流插帧需 `cv2`/`ffmpeg minterpolate`（新依赖，先报代价）、GIF 进 Web 需画廊收录口径从 `*.png` 改多扩展名（**面板刻意没挂 GIF 按钮**，挂了就是点了没反应的出口）。

**第三段补记（2026-10-14 更晚，同一票内）**：① **GIF 已进产品面**——画廊收录口径改成 `png+gif`，批量删除按「一次运行的产物」连整表+sidecar+动图一起清，`/images` 改成按真实后缀发 MIME（顺带修掉既有缺陷：同目录的 mp4/jpg 以前被当 `image/png` 发；非图片后缀现在 400），`gif` 开关正式进表链与视频链参数，真浏览器开 `gif=true` 出图核到 `content-type: image/gif`、8 帧 / `loop=0` / 120ms；反向注入（口径改回只收 png）用例立刻判红，证明它测的是收录口径不是恒绿。② **光流插帧按「最便宜路」实测后否决**：`ffmpeg minterpolate` 虽零新依赖，但对 8 帧匀速平移的 32×32 RGBA sprite 素材**插不出中间位置**（`mci` 与 `blend` 两种模式 19/19 帧 md5 相同、逐像素 max diff 0，前景质心只有原始 8 个位置，帧数 24→19，硬边 alpha 反被弄软）——等价于已有的倍帧算子 + 画质损伤，属假功能；真做需要 `opencv-python` 或自写 numpy 光流，**代价已列明，等老板拍板，未擅自装**。

**第四段补记（2026-10-14 深夜，同一票内，C 族×A 族接点）**：上一轮「视频侧像素化语义未定不硬接」由素材工坊自己的视频链解掉——它排的是 `归一 → pixelate_image(target=帧自身尺寸, scale=1.0) → 再压基线`（其仓全程只读）。据此新增算子 `PixelateFrames`(`Pixel.pixelate_frames`, `FRAMES→FRAMES`) 接在**质量门之后**（量化会抹平相邻帧差异，门必须看原始帧；顺序由 DSL 位置断言钉住），两条 sprite 链都露 `pix_frames` 等四个参数。像素族累计 **18**、全库 **39**。跨仓对拍扩到 **24 组逐像素全等（max diff 0）**，并按分支各配可证伪注入：`>0→>64` 只打红单图批、`阈值≥t→≥200` 只打红逐帧批（6 组）——**每条分支都要有只被它量出来的样本**。真视频 HTTP 实跑：8 帧成品每帧不透明色数正好 4，面板 16 个标签无裸 i18n 键。

**第五段补记（2026-10-14 深夜，同一票内：竞品主流程「四开关同开」端到端）**：把他那条链的终点验到**引擎里真能动**——真 mp4 同开 `逐帧像素化+倍帧×2+引擎导出+动图` 跑 9 节点全链，产物进 Godot 4.4.1-stable 真解析（`frame_count=16`、`speed=8.0`、`loop=true`、16 纹理 64×64、播放 0.5s→第 4 帧 / 1.0s→第 8 帧；引擎回读纹理与源帧 alpha 逐字节同、全不透明区 RGB max diff 0）。同开跑炸出两条**分段测试结构性看不见**的真缺陷：① 帧级段（归一/变换/统一尺寸/倍帧/逐帧像素化）输出与源帧同为 `F###.png`，builder 又统一传同一个 work_dir ⇒ 倍帧读到后面的源帧之前已把源帧覆盖，16 帧塌成一帧（被 `export_gif` 的内容自证抓到）；② Pillow 的 GIF 写入**静默丢掉与前一张相同的帧且不累加时长**（16→8），倍帧接动图等于 2 倍速预览。修法：五个段一律落到自己的子目录；导出前合并连续同帧为「一帧 + 时长×k」，并断言总时长守恒（GIF 10ms 粒度内留容差）。两条各配回归用例，反向注入（把倍帧输出目录注回 out_dir）两轮都判红后才复原。

**第六段补记（2026-10-15，同一票内：交付层收口——他链的终点是「一包」不是一张图）**：竞品最后交给引擎的是**带逐文件校验的素材包**（我方此前只有散在画廊里的表 + 帧 + `.tres` + GIF，没有可核验的包契约）。新增算子 `ExportAssetPack`(`Pixel.export_pack`, `FRAMES+IMAGE→MANIFEST`)，产 `loom.asset-pack/v1`：`manifest.json + sheet.png + frames/F###.png + godot/{sprite_frames.tres,textures/} + anim.gif`，逐文件登记 `bytes/sha256`，逐帧登记 `opaque_pixels / transparent_coverage / content_bounds / pivot_normalized / color_count`——**信息类别**与本票第 7 项保留评估的 `gm-asset-standard/v1` 对齐（其仓仍全程只读、未 import 一行），schema 与键名自定。出口自检按门的口径办：清单与盘上不符、包内路径带 `..` 或绝对路径、某帧全透明 ⇒ `L5022 gate=asset_pack` **阻断且 zip 不落盘**；「没有透明像素 / 超 64 色 / 帧尺寸不齐」只记 warning，不拿提示冒充错误（用例各一条，反向注入全透明帧判红后复原）。zip 条目排序 + 固定时间戳与权限 ⇒ 同内容两次跑 `manifest.json` 与 `.zip` **逐字节相同**；判据打在**包内拷贝**上并由 numpy 独立重算包围盒复核，不用被检方自己的 `_bbox` 自证。产品面：`pack`/`pack_zip` 开关进两条 sprite 链（默认关、默认构图逐字节不变），`GET /files/{name}` 只发 zip 并复用 `SAFE_NAME`，画廊删除口径 `RUN_SUFFIXES` 加 `.zip`。真浏览器实跑（:8127，boss 的 :8000 未动）开「导出素材包」→ `4 帧 · 2×2 · 0.568s` → 结果卡「素材包（引擎中立交付）」，页内 fetch 该链接 `200 / application/zip / 4527B / PK 头`；同面板另一次被质量门 `L5022`（根锚点漂移 0.031>0.03）拦下的素材**不出包卡片**。支路审计累计 **13 判决 exit 0**（新判决现场核 zip 每一项 sha256 与包内帧数对成品 index；表链与视频链两条 `pack` 支路都真跑，真 mp4 那条图内 9 节点出 `walk.zip 10936B/20 项`）。像素族累计 **19**、全库 **40**；全量 pytest exit 0。

**第七段补记（2026-10-15，同一票内：E 族最后一块——勾选与顺序进链，不进预览）**：P3 交付的分帧预览留了个没说破的假象——点缩略图勾选只改浏览器里的播放序列，成品整表 / GIF / 素材包 / 导出的 `.tres` 仍是全部帧。他 E 族（`SpriteSheetAdjust` 2538 行 + `roninProWorkflowGridOps` 596 行）的核心心智是「删帧、换序、再合成再导出」，所以这轮补在链上：新算子 `FrameSelect`(`Pixel.select_frames`，`FRAMES→FRAMES`)，`keep="2,0,1"` 选子集并按给定次序排，`index`/`t_ms` 随新位置重算、像素逐字节照抄、落自己的 `selected_<n>/` 子目录（帧级段不许覆写输入目录那条铁律继续生效）；`keep` 留空 = 一个节点都不加，默认构图逐字节不变。位置排在**质量门之后**——先删帧再让门通过等于拿删除规避质检，顺序由 DSL 位置断言 `quality_gate < select_frames < compose_sheet` 钉住。判据打在产物字节上：勾 `2,0,1` 后新第 0 帧与原第 2 帧逐字节相同、整表随帧数变、同参重跑逐字节相同、越界帧号 `L4003` 且一帧不落。面板分帧条加「按勾选重出 N/M」（recipe 有 `keep_frames` 才出现），把勾选与顺序作为参数重投整条链；过程中又抓出一个**面板自证不一致**并修掉：「查看 DSL」当时仍按表单值渲染，画面 3 帧而 DSL 里查不到 `select_frames`——查看的图与提交的图不是同一张（改为提交时记 `applied` override，两处共用同一份参数）。真浏览器实测（:8128）：`4 帧·2×2·0.053s` → 点掉一帧 → 「按勾选重出 3/4」→ 产物 `3 帧 · 0.048s`，`查看 DSL` 现场显示 `Pixel.select_frames(frames=…, keep='0,2,3', out_dir='')`。支路审计累计 **14 判决 exit 0**。像素族累计 **20**、全库 **41**；D2「不做画布编辑器」照旧——勾选/次序是参数，不是画笔。

**第八段补记（2026-10-15，同一票内：B 族去背——双背景取并集，撞色不再被抠穿）**：他把他家功能页的第一卖点摆在去背：`doubleBackgroundMatte`——同一姿势各出一张白底与黑底，两路 alpha 取并集，「主体色撞上底色也不会被抠穿」。我方此前只有单底色档，撞色必穿（`MatteChroma` 按边框猜色，纯白盔甲配白底就整块消失）。这轮补成**第四条 sprite 链**：新算子 `MatteDoubleFrames`(`Pixel.matte_double`，`FRAMES×2→FRAMES`)，`best = max(dist_a, dist_b)`（两张里「最不像背景」的那张说了算）再按容差带 `[tolerance, min(255, 2*tol+16)]` 线性过渡保住半透明边，RGB 取 A 张；**并集的代价如实写进实现文档**：并集不清杂点，只在一格里出现的噪点会留下，要清杂点请显式走单底色档 + 质量门。新 recipe `sprite_from_pair`：`sheet_a`/`sheet_b` **两个 `file_input` 槽** → 各自 `slice_grid`（必须各写 `slices_a`/`slices_b` 子目录：`_new_work_dir` 给了 `out_dir` 就原样用，同目录会让后切的按同名 `F###.png` 覆盖先切的）→ 并集去背 → 归一 → 质量门 →（勾选/倍帧）→ 合成 →（`.tres`/GIF/素材包，与两条老链同口径）。守卫侧的收获：`/api/sprite` 的防穿越白名单上一轮已改成**按 recipe 的 `file_input` 声明取名单**（fail-closed），所以新增第二个文件槽自动被同一条守卫覆盖，用例把 `../settings.json` 注入**每一个**槽逐一验红——这正是当初手抄参数名漏掉 `image` 那个洞的反面教材。前端从「只有一个文件口」的假设改成 `spriteFileParams` + `files` map，两槽各自上传、各自显示 `up_*.png`，缺哪个槽点名哪个。**真浏览器实跑**（`.venv-spike` 起 :8129，boss 的 :8000 未动，用完按命令行核实 PID 后 kill）：双槽上传 → 行 1 / 列 4 / 单元 64 / 容差 60 + 四个导出开关同开 → `4 帧 · 2×2 · 单元 64px · 0.263s`，结果区同时出「合成整表 / 动图预览 / 素材包（引擎中立交付）」三张卡；`GET /files/sprite_….zip` 现场 `200 / application/zip / 4944B`，包内 12 项 `manifest.json`(`loom.asset-pack/v1`) `frame_count=4`、`frames/F00*.png`、`godot/sprite_frames.tres`+贴图、`anim.gif`，`pack_warnings=[]`；成品逐帧读回**白底撞色的主体仍在**（同一取样区 alpha 既见缺口 0 也见主体 255），四帧 bbox 完全一致过默认质量门。「查看 DSL」显示的就是刚提交那一版（两个 `slice_grid(..., rows=…)` 分支 + `matte_double` + 三个 export），面板诚实口径继续生效。**同轮抓出并修掉一个报错文案缺陷**：像素链判红 `L5022` 时结果卡的固定提示写着「常见原因：显存不足、模型权重路径失效、后端进程已退出」——素材问题把人支去改显存与权重，属把外部/素材原因甩给用户错方向。`TaskErrorCard` 现按码分支（`L5022`/`L5000`/`L4003` → 新键 `task.failHintPixel`：按实测指标改素材，或调面板上的色差容差与行列数），双语同步。新包上**现场证红**：把行数改成 2（下半格全是空 cell）→ `L5022 帧间身高抖动 0.286 > 0.05…`，卡片文案已是像素链口径，「显存不足」字样不再出现。两条口径教训：① 缺口只在主体**内部横向**换位也会挪动 `centers`（alpha 掩膜的横向中心）⇒ `root_drift 0.047 > 0.03` 判红，改成沿纵轴换位后默认阈值直接过——门没冤枉人，是样本没按口径造；② `/files/..%2F..%2Fsettings.json` 返回 **200** 但 body 是 SPA `index.html`（路由 `{name}` 不匹配含斜杠路径，落到 SPA 兜底），**没读到 settings.json**——安全结论必须看 body，拿状态码当判据会得出反向误判。支路审计累计 **16 判决 exit 0**：新增双背景正例（现场核「撞色白块保住 100% + 帧角背景 alpha=0」）与反例 `tolerance=255`（整幅当背景 → `L5000` 拒跑且**不落产物**）；顺手补一条**退化样本守卫**：`pixelate` 那条判决原先拿单色夹具 + 硬编码「≤8 色」，判据恒真量不出限色，现按 `pix_colors` 取上限且要求可见色 ≥2（实测 `colors` 含透明像素占的那格 palette，2 档只剩 1 色可见）。像素族累计 **21**、全库 **42**、recipe **13** 条；回归 全量 pytest **342 项 0 失败 0 错误（2 skip）**、vitest **41/41**、`tsc + vite build` 绿。素材工坊仓仍未碰一行。

**第九段补记（2026-10-15，同一票内：B 族补件——去背后的 alpha 清理，把上一段记下的欠账还掉）**：第八段把并集去背的代价写在脸上（**并集不清杂点**），当时给的出路是「走单底色档 + 质量门」。竞品在同一族里另有 `lib/perfectPixel`（829 行 + `fft` 107 行）专门干这活，所以这条属于「他的管线」而不是可省略备注。本轮补上：新算子 `CleanFramesAlpha`(`Pixel.clean_alpha`, `FRAMES→FRAMES`)——面积 < `clean_min_area` 的**独立前景碎点**丢掉；不接画布边、面积 ≤ `clean_max_hole_area` 的**封闭内部洞**补成前景，且**颜色取「最近的前景像素」**（多源 BFS 逐圈传播：洞底下是被抠掉的底色，只抬 alpha 等于在主体里贴一块背景色补丁）。连通域复用现成 `_flood_runs`，**零新依赖**（照「升级先走最便宜路」）；输出落自己的 `clean_<min>_<hole>/` 子目录（帧级段不覆写输入的铁律继续）。链上位置定在**去背之后、归一之前**：碎点会撑大包围盒，先归一就是拿碎点定标、主体反被缩小，顺序由用例断言 `matte_double < clean_alpha < normalize` 钉住。三条 sprite 链（表 / 视频 / 双背景）共用同一个 builder helper，**两个阈值留 0 ⇒ 一个节点都不加**，默认构图逐字节不变。判据按**定位像素**核、不拿总量糊：碎点区必须透明、洞区必须 alpha 255 且颜色==主体色、主体 775px 一块不许被啃；三条各有专属反向注入（阈值注 1 / 注 0 / 整段拿掉）实测都判红；另配防退化用例「阈值放到 4096 也不许把接边背景当洞填」——否则「补洞」自动退化成整帧变实心、判据恒真。**真端点实拍**（:8130，boss 的 :8000 未动，用完按命令行核实 PID 后 kill）：同一对素材连投两次 `POST /api/sprite`，成品帧最左前景列 **6 → 20**（碎点在主体左边，清掉后主体重新居中），带清理那次 DSL 含节点且位置正确、不带那次根本没有该节点。**面板实拍**：双背景链「显示高级参数」后新出「碎点面积上限（像素，0=不清）」「补洞面积上限（像素，0=不补）」两个控件，双语齐全、页面查不到裸 i18n 键——参数与标签由 recipe 驱动，前端零改动。夹具口径又学到一条：给双背景夹具加的碎点落在 cell 左下，会把两帧**包围盒底边一起撑到同一行**，反而遮住「腿长不同」这个真实身高差——一开清理身高抖动立刻从 0 变 0.195 判红；不是门冤枉人，是夹具的杂点在给缺陷打掩护，正例判决因此显式带上放宽阈值并注明原因。支路审计累计 **17 判决 exit 0**（新判决现场打印「碎点已清、封闭洞已补、主体 775 px 完好」）。像素族累计 **22**、全库 **43**；回归 全量 pytest **349 项 0 失败 0 错误（2 skip）**、vitest **41/41**、`tsc + vite build` 绿。素材工坊仓仍未碰一行。

**第十段补记（2026-10-15，同一票内：D 族补件——自定义切线的非均分切片）**：本票第 4 项当初写的是「N×M 均分 + 透明间隙 + 散点」三种切法，把他 `RoninProCustomSlice`（1900 行）的心智简化成了「格数」——但**不规则图集**（宽窄帧混排、侧边留白、带注释条的表）用均分必然把一个角色切成两半，用间隙投影又要求帧之间有干净空隙，两条都不成立。本轮补上第四支：新算子 `SliceCustom`(`Pixel.slice_custom`，order 234，`IMAGE→FRAMES`)，`x_lines`/`y_lines` 是逗号分隔的**切线像素位置**，n 条线切出 n+1 个条带，首尾边界自动补 0 与图宽/图高 ⇒ 列宽与行高可以完全不等；只定义几何、不做前景判断（抠穿与紧边仍归去背和 `FrameTransform`，口径与本票「先切后抠」一致）。坏切线按参数口径 `L4003` 拒且**一帧不落**：切线越出 `(0, 边长)` 开区间、非严格递增、重复（会切出 0 宽条带）、非整数、两条全空（那不该叫切片，用 `load_sheet`）。编排面：`_slice_by_mode` 增加 `custom` 支，`sprite_from_sheet` 的 `slice_mode` 多一个选项并新增两个参数；默认仍是 `grid`，不写 `slice_mode` 时构图逐字节不变（round-trip 快照不漂移）。判据打在**内容**上：单测逐帧核像素确实取自对应区域（每格一个纯色小人）、列宽必须等于相邻切线之差 `[18,46,32,24]`、横线用例核 3×3 格的 `y/h/index` 逐项对上、同参两次跑逐帧 md5 相同、五条坏切线逐一 `L4003`。**真端点对照实拍**（`.venv-spike` 起 :8131，boss 的 :8000 未动，用完按命令行核实 PID 后 kill）：同一张 120×40 不规则表投两次 `POST /api/sprite`——`grid, cols=4` 被质量门 `L5022 根锚点横向漂移 0.156 > 0.03` 拦下且不落产物（均分确实把人切开了）；`custom, x_lines="22,48,74"` 过门出图，4 帧**每帧前景正好 1 种颜色**、DSL 里是 `Pixel.slice_custom` 而非 `slice_grid`。同素材「一种切法被拦、一种干净出图」才是非均分真的生效的证据，光看帧数对上不算。支路审计累计 **18 判决 exit 0**（现场读回切片列宽 `[60,70,75,95]` 并逐格核前景宽 == 18），三条反向注入各自判红：切线被忽略退回均分 ⇒ 报「列宽不是按切线来的：[75,75,75,75] vs [60,70,75,95]」；少给一条线 ⇒ 几何变化被质量门拦下（不是静默过门）；切线写越界值 ⇒ `L4003` 不落产物。面板实拍：切片方式分段控件出现第四个按钮 `custom`，切过去渲染出「纵向切线位置（像素，逗号分隔，如 18,64,96）」「横向切线位置（留空=不分行）」两个输入框，页面查不到裸 i18n 键；`/api/recipes` 的 `slice_mode.options` 已含 `custom`（参数与标签仍由 recipe 驱动，前端零改动）。两处自伤当场修掉并记入口径：① `zip(rows, rows[1:], strict=True)` 长度不等把自己钉死 ⇒ 用 `itertools.pairwise`；② 审计判决起初去 `work/slices_custom_*/` 找切片帧，而给了 `out_dir` 时 `_new_work_dir` **原样使用 out_dir**，切片帧直接落在 work 根 ⇒ **判据必须按被检方的真实落点写，不能按想象中的目录约定写**。像素族累计 **23**、全库 **44**；回归 全量 pytest **357 项 0 失败 0 错误（2 skip）**、vitest **41/41**、`tsc + vite build` 绿。素材工坊仓仍未碰一行。

**跨仓改动范围声明**：素材工坊全程**只读**（读 `sprite_alignment.py`/`video_sprite_quality.py` 定口径、跑对拍），不改其一行代码；如需回采 Loom 算子另票。

## 验收标准

- [x] **算子数**：像素族 **14 个**（票面 ≥10），每个都有直接调用其实现的单测（`grep` 计到 14/14 命中）；`loom lint examples/sprite_from_video.py` → `✓ 校验通过：3 个节点，0 条警告`。验证口径：`ls registry/ops | wc -l` = 37 文件 / 注册表 35 算子（21 生图 + 14 像素，`test_registry.py` 冻结）。**同日更晚复算**：像素族 **17**（+ `ExportAnimatedGif`/`FrameRepeat`/`Pixelate`）、全库 **38**，冻结集合同步。
- [x] **端到端**：P0 实跑（lavfi 造 2s/16fps/64×64 视频 → 抽 8 帧 → auto 去背 → Sheet，0.1s）；帧数/`frame_size`/每帧 `(x,y,w,h,t_ms)` 与手算布局公式一致（`index.json` 现场读回校验 `layout_ok True`，单测 `test_sheet_compose_layout_matches_hand_formula`）。**2026-10-14 晚复跑**：`.venv-spike/Scripts/loom.exe run` 对真视频再跑通（`steps=3`、0.6s、`sheet.png` 37013B + `index.json` 1719B，`t_ms` = 0/125/…/875 与 1/8s 步进等）；期间本机 ffmpeg 三档曾全落空 → 按 `L5021` 自己的指引补装 tier-3（`uv pip install --python .venv-spike/… imageio-ffmpeg`，不动 lock），装完即恢复。**复跑抓出真缺陷**：`video_extract_frames` 的 `fps`/`max_frames`/`start_s` 有 schema 默认值却无 Python 缺省值 ⇒ 裸 DSL 省略即 `TypeError`（`examples/sprite_from_video.py` 恰好省略 `start_s`，示例照抄跑不动；P0 的脚本显式传参把它盖住了）→ 三个形参补齐 + 新增契约闸 `test_optional_schema_params_match_impl_defaults`（遍历像素族：可选参数必须有同名形参与一致缺省值；反向注入摘掉 `duration_s` 缺省值可证红）。
- [x] **数值对拍**：`scripts/pixel_alignment_parity.py` 只读引素材工坊 `sprite_alignment.normalize_sprite_frame`，20 帧样本 → **最大偏差 0.00px**（阈值 2.0px）`PASS`，exit 0；报告 `spindle/docs/worklog/2026-10-14-pixel-alignment-parity.md`。
- [x] **确定性回放**：同参数连跑两次，`sheet.png` `cb22aae8b634780437645251e48878f4` 与 `index.json` `dd11caf7abece3b206009ef9b7e8da3a` **逐一相同**（2026-10-14 CLI 复跑）；视频路同日再复跑一对也相同（`423687e9292350037510189a34f46fc1` / `b0224d3b12374ce44c7505c10d0b86fa`，比对前先核 `md5sum` 输出行数=2，防「空对空也算相同」的假绿）。
- [x] **引擎真加载**：Godot 4.4.1-stable（非 mono）空工程真放出 walk 动画——`animations=["walk"]`、`frame_count=8`、`speed=8.0`、`loop=true`、8 帧纹理 48×64 全解析、0.5s→frame 4、1.0s→回卷 0；窗口模式截图 480×320。UI 侧导出的 `.tres` 同生成器（`load_steps=9`、`"loop": true`）。取证 `spindle/.tmp/godot_check/`，结论记 worklog §三。
  **同日补真素材版**：把 10-13 生成链的真产物（角色「锈钩」8 帧 sheet）过像素链导 `.tres` 再进同一工程 → 结构证据全同（8 纹理 64×64、0.5s→4、1.0s→回卷）。逐字节核对据此**收紧了口径**：alpha 通道逐字节同、`alpha==255` 的全不透明像素 RGB 差 **0**；`0<alpha<255` 的半透明边缘在 `get_image()` 回读时受引擎预乘影响会有差（max 171 / mean ~4），属引擎回读行为而非导出丢数据。跨链审计常驻脚本：`spindle/scripts/cross_chain_audit.py`（三态退出，三种状态都实测过）。
- [x] **质量门可判红**：反向注入 = 取归一后的 8 帧、把最后一帧主体整体下移 8px（被单元底边截掉一部分，实测基线漂移 4px）→ `loom run` 报 **`L5022 像素质量门判红：脚底基线漂移 4px > 2px`**、exit 1、`sheet.png` **不产出**（阻断不留半成品）；日志实测值 `{"frames":8,"ground_drift_px":4,"height_jitter":0.078,"loop_gap":0.078,"min_frame_diff":0.374,"root_drift":0.0}`。单测另钉「六帧全同 → 判红」与 recipe 级阻断。
- [x] **文档回写**：`spindle/docs/PRD.md` §4.1 算子族节（14 算子 + 入口行）+ `ROADMAP.md` MVP-6（P0–P3 逐期勾选）+ `FLOW-generate.md` 副链图与 Web 入口段 + `API-SPEC.md`（`/api/sprite`、`/api/sprite/export`、`/api/upload` 收视频、错误码 L5021/L5022）+ `UI-IR-SPEC.md`（`preview.type=="frame_strip"`）+ worklog P0–P3。**`ops/nodes.json` 无 spindle 节点**（`grep spindle` = False）⇒「改 flows + 跑 flow_docs.py」这半项对本仓 N/A；是否新登记一个 spindle 节点待老板拍板，未擅自新增。
- [x] **登记回写**：`python ops/products.py check` exit 0 无输出（无新增缺口）；`python ops/demand.py check` 99 票「硬伤 1，软账 127」与本票无关（本票未列入告警）。本票验证口径即本节全文。
- [x] **合规**：全仓 `grep -rniE "rpgmakerv1|tunshitiandi|xiaosuJianshi|frameronin|systemchester"` 在 `registry/ packages/ scripts/ examples/` 只命中两处——一条 recipe 注释（写明「自实现，不搬其代码与文案」）与一条回归断言 `assert "frameronin" not in text`；无源码片段、无 preset 命名、无文案原文。素材工坊仓全程只读（对拍脚本只 import 其函数）。

### 明确不做（边界）

- 不做 relay/中转/代充/次数计费，不做 NFT 与 Ronin 链门槛（那是他的变现层，与 Loom 定位无关）
- 不做无限地图（`InfiniteMapScene` 2716 行）、不做 RSESPRITE 像素编辑器、不做画笔/橡皮类画布交互（等 D2 重启条件）
- 不做对外网页版：Loom 保持 internal 单机本地工具，转 external 与标配四端点随 M11 桌面端另议
