加入手动完美像素入口;像素艺术勾选时自动补提示词约束 #124
@@ -138,10 +138,10 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c
|
||||
}
|
||||
```
|
||||
|
||||
The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing:
|
||||
The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing:
|
||||
|
||||
- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning.
|
||||
- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation.
|
||||
- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning.
|
||||
- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it.
|
||||
- Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`.
|
||||
- Non-string JSON values: malformed request, HTTP `400`.
|
||||
|
||||
|
||||
@@ -2991,7 +2991,7 @@
|
||||
"none",
|
||||
"pixelArt"
|
||||
],
|
||||
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
|
||||
"description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 仅支持普通图片(kind 省略)和 character,会在提示词末尾追加一行像素风约束(普通图片为「画面为像素风格」,character 为「角色主体为像素风格」)并在回图后执行像素规整。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
@@ -3405,7 +3405,7 @@
|
||||
"none",
|
||||
"pixelArt"
|
||||
],
|
||||
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
|
||||
"description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 会在提示词末尾追加一行「每个图标素材均为像素风格」并启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-31 图集切片按需编码并批量确认持久化
|
||||
|
||||
- 背景:`2026-07-29 图集切片必须受前置容量和有界 CPU 保护` 收口了连通域数量与 CPU 并发,但切片仍在一次循环里全部裁剪并编码,最多 64 份 PNG 字节连同整张 RGBA 同时驻留内存;持久化又按切片逐个调用 procedure,N 片至少 2N 次写入外加一次 cohort 完成,任一片失败都会留下已确认的部分记录。手动拆分入口另有一处重复鉴权:`get_editor_project` 已经取回并定位了来源资源,随后仍走 `parse_editor_reference_image` 按注册 ID 再解析一次,触发全账号项目与素材库扫描。
|
||||
- 编码与内存决策:`platform-image` 把切片拆成 `prepare` 与 `encode` 两步,`prepare` 只计算带 padding 的裁剪边界并持有 `Arc<RgbaImage>`,`encode(index)` 被调用时才裁剪并编码单片。裁剪阶段累计 padding 后像素,超过调用方传入的上限即在任何编码前返回 `TotalCropPixelLimitExceeded`;api-server 传 `EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4`(`16777216` 像素),映射为 `422` 与 `crop-pixel-limit-exceeded`。编码与上传由 `buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)`(`2`)串起,同时最多两片 PNG 在内存中。
|
||||
- 准入决策:新增独立于既有 CPU 信号量的 `EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER`(`EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY = 2`)。手动拆分在创建下载客户端和发起下载**之前**取得该许可,许可覆盖「下载 → prepare → 逐片编码 → 逐片上传」整段,在进入 SpacetimeDB 批量调用前显式释放,避免数据库慢调用继续占用整张 RGBA。门限不可用返回 `503`、等待超预算返回 `504`,两者共用既有 `slice-processing-timeout` code。自动生成路径复用同一许可,但其源图此前已在内存中,该许可只保护解码与连通域阶段,不覆盖下载。
|
||||
- 持久化决策:新增 procedure `persist_editor_spritesheet_slice_batch_and_return`,在单个事务内依次确认每片的 asset object、可选项目资源、可选账号素材,并在存在 `group_task_id` 时一并完成 cohort;每次拆分请求只调用一次。批次上限 `EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS = 64`,写入前校验数量与 `expected_asset_count` 一致、批内 `assetObjectId / objectKey / resourceId / assetId` 不重复、`source_resource_id` 指向的既有资源存在且同 owner 同 project;需要完成 cohort 的批次必须每项都创建素材。切片记录 ID 由 `(ownerUserId, taskId, 切片序号)` 经 SHA-256 确定性派生,重放得到相同 ID,且只有既有记录与新输入逐字段一致时才幂等复用,否则报幂等键冲突。
|
||||
- 鉴权决策:手动拆分不再调用 `parse_editor_reference_image`,直接用已随 owner-scoped 项目读取取得的 `source_resource` 取 objectKey,典型路径的 SpacetimeDB 调用从 3 次降为 1 次。作为替代,新增显式三重断言——项目属于当前 owner、资源属于当前 owner、资源属于当前 project——任一不符返回 `403`。结构断言禁止该区间再出现 `parse_editor_reference_image` 或 `list_editor_projects`。
|
||||
- 传输边界:新增 `build_editor_spritesheet_http_client(connect, request)`,下载与上传共用同一组常量 `EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT = 10s`、`EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT = 60s`。
|
||||
- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs` 及生成的 module bindings;图标图集手动拆分与自动拆分链路。新增 SpacetimeDB procedure 与输入输出类型,需要重新生成绑定。
|
||||
- 验证方式:`platform-image` 覆盖 prepare 不编码且 `Send + Sync`、并发编码多个 index 结果不变、累计裁剪像素在编码前拒绝;`api-server` 覆盖切片记录 ID 稳定且按 owner / index 分区、自动路径保留处理超时告警码、上传超时释放内存许可;`spacetime-module` 覆盖批次校验的完整 cohort、重复 objectKey、来源资源同 owner 同 project、部分 cohort 拒绝与重放只在内容一致时复用。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件 `2026-07-29 图集切片必须受前置容量和有界 CPU 保护`。
|
||||
- 补记说明:本条为事后补写,记录提交 `cf1a02312` 已落地的行为,不改变其任何决策。
|
||||
## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG
|
||||
|
||||
> 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。
|
||||
@@ -5950,6 +5962,16 @@
|
||||
- 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。
|
||||
|
||||
## 2026-07-30 已有静态图片增加免费一键完美像素化
|
||||
|
||||
- UI 决策:图片选中浮动工具栏的栅格处理顺序固定为 `裁扩 → 去除背景 → 完美像素`。完美像素只对当前活动的静态栅格图层一键执行,不打开参数面板;音频、视频、图片序列和 `character-animation` 不显示。请求期间按 layer id 禁用并显示 busy,首个 await 前用同步 ref 防双击重复提交;结果保留源图并在右侧新增同尺寸 PNG。
|
||||
- API 与执行边界:新增登录态 `POST /api/editor/images/pixel-art-snaps`,复用 `platform-image` 纯内存 snapper、进程级 CPU 并发 2 以及既有输入尺寸上限。该入口免费 inline,不调用外部 provider,不创建 `external_generation_job`,不打开或刷新任务侧栏,也不进入泥点扣费 / 退款;它与生成请求 `style="pixelArt"` 的 best-effort 后处理是两个契约。2026-07-31 修订:并发控制改为两层——端点级并发闸最大 4、等待队列上限 2048,必须在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层仍是共享的 CPU 并发 2。30 秒预算的起算点同时从「下载完成后」前移到 handler 入口,现在覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整全过程,而不再只是 CPU 排队加处理。该端点的 OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端,不再每次新建无超时客户端。来源解析同时对齐图集拆分:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图时,来源资源已随 owner-scoped 项目读取完成鉴权,改为显式断言 `resource.ownerUserId` 与 `resource.projectId` 后直接取用其 objectKey,不再做全账号项目与素材库扫描;两个字段指向不同图片直接拒绝,不退回扫描路径。跨记录 asset_kind 扫描随之省略,存储类型点查保留,动图仍由下载后的静态编码门禁按实际字节拒绝。
|
||||
- 媒体与归属:前端先创建关闭 composer 的右侧占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局;正式请求使用 `sourceImageSrc` 承载源图 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`。请求禁止 `data:` / `blob:`、signed URL 和普通外链。BFF 下载前必须将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属。
|
||||
- 失败与持久化:已有图片入口使用 strict 语义,只接受静态 PNG / JPEG / WebP,拒绝 GIF、APNG、动画 WebP 和非静态素材。strict 完全复用生成风格的 legacy profile、峰值估算、单轴步长补全、walker、采样与编码;唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,strict 与 legacy 行为及输出必须一致。读取、解码、输入校验、并发排队、像素规整或 PNG 编码失败 / 超时 / 不适用时,不保存原图副本冒充成功,不执行最终 OSS PUT,也不创建 asset object、project resource、账号素材或结果 layer。成功时只对最终 PNG 做一次 PUT,至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果以 `source_resource_id` 关联该资源,再由 `canvasCompletion` 写入至多一个右侧派生 layer;不保存逻辑低分辨率图、诊断图或前后对比图。
|
||||
- 非事务边界:strict 零写入只覆盖首个最终 PNG PUT 前的引用 / owner / 项目 / 类型 / 静态编码 / 元数据 / 网格适用性 / CPU 处理门禁。进入持久化后沿用现有 `OSS + asset object → project resource → editor asset → canvas completion` 非事务顺序,后段失败可能保留此前已确认对象或记录;不做删除补偿或 unsafe POST 自动重放,按 `task_id / object_key / resource_id` 读取权威快照排障,跨系统单事务留待独立 procedure 方案。
|
||||
- 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。
|
||||
- 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。
|
||||
## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用
|
||||
|
||||
- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。
|
||||
@@ -5966,6 +5988,45 @@
|
||||
- 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。
|
||||
- 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。
|
||||
|
||||
## 2026-08-01 生成风格 pixelArt 同时约束提示词
|
||||
|
||||
- 背景:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词拼接。但 `platform-image` 的 snapper 是几何对齐器——先检测网格步长再按格重采样;provider 交一张柔和渐变图时横纵两轴都检测不到步长,生成路径使用的 legacy profile 会退到 `min(width,height)/64` 统一网格兜底,产出的是马赛克而不是像素画。也就是原语义等于「随便生成什么,然后强行网格化」。
|
||||
- 决策:`pixelArt` 从「纯后处理风格」改为「提示词约束 + 后处理」。注入点固定在 `generate_editor_image_for_owner` 与 `generate_editor_icon_spritesheet_for_owner` 各自构造 `submitted_prompt` / `prompt` 的位置,包住既有 builder 的返回值,builder 签名与其既有输出契约不变。三个入口(登录态路由、外部 API v1、异步 job worker)都汇聚到这两个函数,一处注入全覆盖。`None` 必须原样返回原提示词。
|
||||
- 作用域按链路分三条措辞,不共用同一句:普通图片没有抠像底色,用「画面为像素风格」;角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整,分别用「角色主体为像素风格」和「每个图标素材均为像素风格」,都不得出现「画面」级别的像素化要求,否则与同一段提示词里既有的「纯色背景必须平整无纹理、无渐变」互相拆台。角色形象的提示词已禁止出现角色以外的场景内容,因此只点名角色;图标图集一张图内是多个彼此分离的素材,需要逐个点名。
|
||||
- 强度边界:实测只提「像素风格」效果已可接受,因此不注入网格密度、色板色数、抗锯齿等约束。约束句一律追加在提示词末尾并独立成行,不前置、不改写 builder 内部语句。`kind` 为 `spec / quick-edit / ui-design / publication-material` 时 `pixel_art_supported` 已把 `pixelArt` 降级为 `None`,注入对它们不生效;「修改图片」链路的 DTO 没有 `style` 字段,完全不受影响。
|
||||
- 反向提示词:同步从画布四个生图入口(普通图片、角色形象共用一条,UI 设计图,修改图片两个 provider 分支)的 negative prompt 中移除「低清晰度」——该词按字面否定低分辨率,与以低分辨率重采样为本质的像素风直接对冲。其余玩法(拼图、消除、跳跃、方洞、大鱼、吠叫、自定义世界场景图)的同名词条不动,本次只收口画布项目。
|
||||
- 持久化影响按链路不同,不能一概而论:`output_prompt` 初值是 `submitted_prompt`,但只有普通图片会保持到最后写入 `editor_project_resource` 的 prompt 列,该列因此从存用户原文变为存原文加一行约束句。角色形象链路的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`,其原图 project resource 存的是 `role_setting`(用户原文),因此约束句在角色的任何 project resource 里都不出现。图标图集链路的原图 spritesheet resource 存工程化提示词(含约束句),透明结果存 `"去除纯色背景"`,自动拆分的切片存 `"自动拆分图集"`。不新增 OSS PUT、项目资源、素材记录或画布图层。
|
||||
- 角色链路的完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),回了就存 provider 改写后的文本。因此 provider 回 `actualPrompt` 的场景下 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。排障时按 `object_key` 查 asset object 元数据只在前一种场景下有效。该行为与 `web/master` 逐行一致,属既有可观测性缺口,本次未改。
|
||||
- 响应体三条链路并不一致:普通图片和角色形象返回 `role_setting`(用户原文),前端显示不变;图标图集返回的是 builder 构造并追加约束句后的工程化 `prompt`,即调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),「返回用户原文」对它不成立。该响应字段行为同样与 `web/master` 一致,本次只是让被回传的模板多了一行。
|
||||
- 上述 prompt 列的写入规则全部是既有行为,与 `web/master` 逐行一致,本次未改动一行。但该列同时被用户侧素材库搜索(`buildAssetSearchValues` 把 `asset.prompt` 计入匹配项)和后台素材查询页读取,而它当前混着三种语义:用户输入、工程化提示词、以及派生步骤描述。由此带来的模板噪声污染搜索(角色模板含「绿幕」「纯色背景」等词)、派生产物按源提示词搜不到(透明图的 prompt 列是 `"去除纯色背景"`)等问题均为存量,需单独立项与原设计者对齐后再动,不在本次范围内。
|
||||
- 未覆盖:图标图集尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏);角色形象提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,未改写。两项都等实测。snapper 当前无任何日志,`resolve_step_sizes` 走检测还是走统一网格兜底在外部不可观测,注入效果暂时只能靠人工看图判断。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
## 2026-08-01 完美像素端点补齐保留审计字段剥离
|
||||
|
||||
- 缺陷:`POST /api/editor/images/pixel-art-snaps` 自新增之日起未调用 `sanitize_editor_client_generation_inputs`,只对 `generationInputs` 做了可序列化性校验(`serialize_editor_asset_metadata`)便原样写入 `editor_project_resource` 与 `editor_asset`。登录用户因此可以自行声明 `screenColorHex / mattingProvider / mattingModel`,让后台 raw mapper 看到伪造的处理元数据。该 sanitizer 与其余 14 个生产调用点(`editor_project.rs` 13 处覆盖普通图片、角色、图标图集、UI 设计、快速编辑、抠图、上传等,`external_editor_api.rs` 1 处)在此端点加入前就已存在,属于新端点漏配既有约定,不是设计取舍。补上本端点后生产调用点为 15 个。
|
||||
- 决策:在 handler 解析 payload 之后、任何 IO 之前调用同一个 sanitizer,位置与其余入口一致。这三个字段是服务端产出的处理事实——`screenColorHex` 由背景色决策写入,`mattingProvider / mattingModel` 由 `apply_editor_matting_metadata_to_generation_inputs` 在 bgfilter 实际执行后写入——一律不接受客户端声明。完美像素是纯几何规整、不抠图(`model = "Perfect Pixel"`、`provider = "Genarrative"`),任何 matting 元数据出现在这类记录上本身就是伪造。
|
||||
- 影响边界:只能污染攻击者自己的记录(`owner_user_id` 取自 access token,不可控),不构成越权、信息泄露或计费漏洞;该端点 `generation_cost_mud_points = 0`。危害限于按这些字段做的后台统计、排障与审计出现假数据。
|
||||
- 验证:sanitizer 单元测试 `editor_client_generation_inputs_cannot_forge_internal_audit_fields` 已覆盖字段剥离与其余字段保留;端点接线由 `explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 的顺序断言钉住,`sanitize_editor_client_generation_inputs` 必须排在 `resolve_editor_pixel_art_processing_deadline` 及之后全部 IO 之前,被挪到 IO 之后会直接失败。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-01 完美像素按钮补齐素材类型保存门禁
|
||||
|
||||
- 缺陷:完美像素按钮自新增之日起未接入 `isPersistingAssetKind`,handler 也未复用 `persistingAssetKindLayerIdsRef` 同步守卫。把图层的非空 `assetKind` 改成另一个非空值后,在异步资源保存完成前点击该按钮,请求会同时带上新 `assetKind` 和旧 `sourceResourceId`,后端 `resolve_editor_pixel_art_snap_asset_kind` 检出请求类型与来源权威类型不一致直接返回 `400`。两道防护与相邻的拆分图集按钮在完美像素按钮加入前就已存在,属于新入口漏配既有约定。
|
||||
- 决策:完美像素按钮的 `disabled / aria-busy` 与拆分图集共用同一套门禁(`isPersistingAssetKind || isPerfectPixelProcessing`),handler 侧同样先查 `persistingAssetKindLayerIdsRef` 再提交——`disabled` 只挡下一帧,同步 ref 才挡得住 `setState` 生效前的那一次点击。
|
||||
- 无障碍:保存态名称不得直接复用拆分图集的「素材类型保存中」。`icon-spritesheet` 图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有测试也会因 `getByRole` 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的「完美像素等待素材类型保存」。
|
||||
- 影响边界:`400` 发生在纯函数前置校验阶段,此时尚无 OSS PUT 与资源创建,不写坏数据;用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为另一个非空类型」时触发——权威类型为空时走兜底分支不比较。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-01 完美像素归属校验单次取数并纳入处理预算
|
||||
|
||||
- 缺陷:`POST /api/editor/images/pixel-art-snaps` 在合法 `assetId` 且不带 `sourceResourceId` 时,取得端点准入许可后到 OSS 下载之间会顺序执行 8 次 SpacetimeDB 调用,且全是裸 `await`,第一次真正应用 30 秒预算的是下载。其中 `list_editor_projects` + `get_editor_asset_library` 这对全账号扫描重复了三轮——`resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,`resolve_editor_pixel_art_source_for_owner` 再扫第三轮,拉的是同一份数据。
|
||||
- 契约澄清:预算从 handler 入口「起算」不等于「覆盖」。此前文档写成覆盖 SpacetimeDB 读取是错的:deadline 是绝对时刻,早起算只让后续余额更少,中间不检查就不会在该阶段返回 `504`,请求会一路走到下载才失败并返回下载相关文案,误导排障。同时端点准入许可全程被这些无界调用占用,把并发闸自身变成瓶颈。
|
||||
- 决策一(去重):参照抠图入口 `resolve_editor_background_removal_source` 的既有范式——扫一次,注册 ID 解析、归属校验和跨记录 `asset_kind` 收集全部交给 `_from_records` 纯函数在内存里完成。`resolve_editor_pixel_art_source_for_owner` 自行取一次 owner 快照后复用,不再调用 `resolve_editor_reference_object_key_for_owner`;该包装是给没有任何上下文的调用方用的,保持不动。归属校验命中已登记记录即短路,只有两份记录都查不到时才回落 `ensure_editor_reference_asset_object_owned` 点查。全账号 RPC 由 6 次降为 2 次。整段合法 `assetId` 链由 8 次降为 4 次——`get_editor_project`、`list_editor_projects`、`get_editor_asset_library`,加上恒定执行的 `get_asset_object_by_location`(承担存储 taxonomy 的非静态门禁,成本与账号规模无关,两条路径都保留);objectKey 未登记在 owner 任何记录里时多一次 `ensure_editor_reference_asset_object_owned` 的点查,最多 5 次。该兜底分支上同一个 objectKey 会被点查两次(归属校验一次、存储门禁一次,参数相同),可合并但收益远小于已削掉的全账号扫描,暂不处理。
|
||||
- 决策二(预算):`get_editor_project` 到来源解析结束整体包进 `tokio::time::timeout_at`,超时返回 `504` 且文案指向归属校验而非下载。不重复写 `Instant::now() >= deadline` 预检——紧邻的 `acquire_editor_pixel_art_snap_permit` 已做该预检,成功即意味着未超预算,且本块首个 await 是网络 IO 不会立即就绪,不构成 `timeout_at` 先 poll 再判超时的陷阱。
|
||||
- 验证:顺序断言新增 `tokio::time::timeout_at(` 与超时文案;参照 `937378ab9` 的做法用 `assert_function_occurrence_count` 把 `resolve_editor_pixel_art_source_for_owner` 内的 `.list_editor_projects(` 和 `.get_editor_asset_library(` 各钉为 1 次,并用 `assert_function_not_contains` 禁止该函数重新调用取数包装。后者断言的是调用形式 `resolve_editor_reference_object_key_for_owner(state` 而非裸函数名,否则会命中生产代码里说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-01 game-chat 首版四阶段快车道与美术硬门
|
||||
|
||||
- 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。
|
||||
@@ -5978,6 +6039,40 @@
|
||||
- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
## 2026-08-01 完美像素未知结果先对账再定性
|
||||
|
||||
- 缺陷:`snapImageToPerfectPixels` 的 catch 对所有错误一视同仁——标 `failed`、`finally` 解锁、按钮恢复可点。transport 异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成 OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户按提示重试就再造一整份对象、资源与素材。这直接违反本功能自己立下的契约:「结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行」。客户端未配 `EDITOR_REQUEST_RETRY_OPTIONS`(禁自动重放)这半条一直是达标的。
|
||||
- 判别依据:`ApiClientError` 只在拿到服务端 `Response` 时由 `buildApiClientError` 构造,transport 异常、`AbortError` 和 `TimeoutError` 在重试判定后原样抛出。因此 `error instanceof ApiClientError` 即「服务端明确响应过、结果已知」,其余一律按未知处理。已知结果不发对账 GET,避免每个 `400` 都多打一次权威读取。
|
||||
- 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。
|
||||
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。
|
||||
- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。
|
||||
- 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView` 的 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-01 完美像素持久化阶段由服务端显式告知客户端
|
||||
|
||||
- 背景:上一版对账用 `error instanceof ApiClientError` 判定「结果已知」,即「服务端响应过就等于没落库」。这个等式不成立——持久化非事务,`complete_editor_canvas_generation` 走 CAS 写入,冲突时 `spacetime-module` 抛「图片画布版本冲突」,经 `map_editor_project_error` 变成 `409`;`403 / 404 / 400` 同理。也就是说带响应的 4xx 同样可能发生在 OSS 对象、asset object、project resource 和账号素材全部落库之后。完美像素要跑满 30 秒,用户在这期间改动画布把 revision 推进并不罕见,因此该场景触发频率高于最初估计。
|
||||
- 否决的两个方案:把所有 POST 后失败都当未知(每次常见校验失败多两次读取,且给不可能产生素材的场景附上「请核对素材库」的不适用提示);按状态码分类(`409` 确实只来自写操作,但 `403 / 404` 和 `5xx` 在持久化前后都会出现,分不干净,等于把猜测写进代码)。
|
||||
- 决策:由服务端显式告知。`AppError` 新增 `with_detail_field`,在已有 details 上补字段而不是像 `with_details` 那样整体替换,保留下游写入的 provider / message——客户端要靠 message 定位、靠新字段决策。`snap_editor_image_to_pixel_art` 在第一次 OSS PUT 之后的四条失败路径(账号素材持久化失败、项目资源缺失、账号素材缺失、画布回填失败)置 `resultPersistenceStarted: true`。客户端只对「完全无响应」和「带该标记」的失败做对账,常见的纯校验 `400`、排队 `503`、预算 `504` 既不多打读取也不附加提示。
|
||||
- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。
|
||||
- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。
|
||||
- 文案边界:对账尾句只下指令、不断言素材库已刷新。`refreshAssetLibrary` 在 `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时弹登录框、其余一律吞掉,返回 `Promise<void>` 不带成败信号,且它本身是可选 prop——三种情况下「已刷新」都是假话,会让用户对着旧列表判定「没有派生图,可以重试」,重新走回这条修复要避免的重复创建。刷新照旧调用(成功时用户白赚一份新列表),但文案在刷新失效时也必须成立。
|
||||
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口(已由 2026-08-03 的 `requiresLiveSession` 条目解决)。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素刷新后的孤儿占位由归属标记收口
|
||||
|
||||
- 缺陷:完美像素在 POST 前 `flushProjectPersistence()` 把 `status: 'generating'` 的占位落库,随后走同步 HTTP。此时刷新页面,浏览器断连、handler future 被丢弃,服务端不会走完画布回填;新页面 hydrate 时 `status` 被原样还原(`isGenerationStatus` 认 `generating`),而加载期没有任何对账、轮询或重试 GET,占位就永久停在转圈状态。它还消不掉——Esc 被 `useImageCanvasKeyboardShortcuts` 的 `status === 'generating'` 挡,composer 关闭被 `closeCanvasGenerationComposer` 的同一判断挡。
|
||||
- 归因:序列化设施是存量,但这个组合是本分支首次出现。同类路径逐条比对——去除背景的 `EditorBackgroundRemovalResult` 的 `queueState` 是非可选字段,恒走 durable job,worker 会在服务端替换占位;拆分图集根本不创建占位;图片生成等提交类链路在默认 `ExternalGenerationMode::Queue` 下同样入队,只有显式设 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 的部署才同步执行。完美像素的 handler 353 行全同步,`tokio::spawn` / `enqueue_` 各 0 次,`EditorPixelArtSnapResult` 无 `queueState`,是唯一「持久化 generating + 无 durable job」的链路。
|
||||
- 方案取舍:不能从写入侧解决。`validate_editor_pixel_art_snap_placeholder_exists` 要求占位必须已落库,否则返回 `409`「完美像素画布占位不存在或尚未保存,请重试」——不持久化占位会让每一次完美像素都失败。POST 前那句 flush 正是为满足该门禁而存在。因此只能在读取侧收口,纯前端。
|
||||
- 决策:给 dialog 增加 `requiresLiveSession` 标记,加载时由 `dropDeadInlineGenerationPlaceholders` 剥离置位且仍为 `generating` 的占位。判据是结构性不变量而非时间阈值:这类占位的收口只能由创建它的会话完成,而活着的那一份始终在内存里、永远不经过快照 hydrate,所以凡是从服务端快照读回来的必然属于已死会话。因此不需要时间戳,也不必猜阈值。队列型占位一律不置位——它们的 job 在服务端继续跑,误清会让用户以为操作没发生而重复提交。hydrate 只认布尔 `true`,缺字段的历史占位按队列型处理,不会被误清。
|
||||
- 作用域:剥离只用在项目首次加载的两个调用点(会话缓存与权威快照都要,否则首屏会先闪一个永远转圈的占位)。**不能**下沉进 `hydrateCanvasGenerationDialog`——会话内 `applyQueuedEditorGenerationProject` 也会重新 GET 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。唯一置位点是完美像素占位的创建处。
|
||||
- 提示文案:服务端持久化顺序 `OSS PUT → asset object → project resource → editor asset → 画布回填` 是非事务的,加载时还看到 `generating` 只说明最后一步没做完,前面几步可能已成功。所以不能断言「什么都没发生」,只提示「画布占位已清理,请确认素材库是否已生成派生图」,与同链路的对账文案同一口径。计数用累计值而非布尔——同一会话可能连着切换多个项目,布尔只提示一次。
|
||||
- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。
|
||||
- 更正(2026-08-03):上一条里「多标签页另有 CAS `expected_revision` 兜底」是错的。CAS 挡的是基于陈旧 revision 的覆盖写,而 B 是以**当前** revision 写入一份合法布局,必然放行。触发也不需要 B 去点完美像素——B 加载后任何布局改动都会触发防抖保存,把「不含 A 占位」的布局写回服务端。后果已核到底:`complete_editor_canvas_generation` 在占位缺失时不报错,走 `Ok(None)`,A 的响应是 200 且 `project: null`,A 的前端据此移除本地占位并提示「完美像素结果已保存到素材库,画布占位已不存在」。所以 A 的 OSS 对象、项目资源、素材库记录三样都在,用户也被准确告知,丢的只是画布自动落位,需手动从素材库拖回。定级 P3,真正的修法是给占位加会话归属标识、只允许创建者剥离,单独立项。
|
||||
- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 game-chat 开发态前后端同源与快车道恢复
|
||||
|
||||
- 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。
|
||||
@@ -5997,6 +6092,61 @@
|
||||
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保留点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,CDP 点击返回后再收口该冻结值,因此后注册的同步 click listener 仍会计入,而 RAF / timer 任务不会污染因果证据。页面全局对象不能改写隔离世界证据,capture-phase、stopPropagation 与真实 window bubble 处理器均应正确验收。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-03 完美像素对抗性审查第一批修复
|
||||
|
||||
- 范围:本分支相对 `web/master` 的 17 条审查发现里,只修其中三条——它们互相独立、改动小、无需设计决策。其余按链路分批,队列化改造(把完美像素接进 `enqueue_editor_generation_job`)因改动面超出本分支预期而未采纳。
|
||||
- A1 持久化标记漏洞:`snap_editor_image_to_pixel_art` 对 `persist_editor_generated_image_owned` 用的是裸 `?`,而该 helper 内部顺序是 `PUT → HEAD → confirm_asset_object`。HEAD 或 confirm 失败时 OSS 对象已存在,错误却不带 `resultPersistenceStarted`,客户端的 `outcomeMayBePersisted` 因此为假、对账根本不执行,直接报普通失败;用户重试会用新 `task_id` 生成新 object key,首个对象成为无从发现的孤儿。这是三条里唯一会让收口机制完全不触发的。
|
||||
- A1 的标记边界:标在 helper 内部而不是调用点——调用方拿到的是同一个 `AppError`,无法自行判断内部走到了哪一步。边界取在第一次 PUT:`prepare_put_object` 与「OSS 未配置」这两处失败都在 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是与本条镜像的反向谎报。PUT 自身也标——响应丢失时字节可能已落盘,属于契约要覆盖的未知结果。共四处:PUT、HEAD、asset object 入参构造、`confirm_asset_object`。该 helper 为 9 条编辑器持久化流程共用,新增的 details 字段对其余调用方语义同样成立(持久化确实已开始),只是目前只有完美像素前端消费。
|
||||
- A3 失败反馈第三态:catch 尾部只处理「有占位」与「没有 dialogId」,缺「有 dialogId 但占位已被删」。删除生成中占位是产品支持的流程(`requestRemoveCanvasGenerationDialog` 对 `generating` 会先弹确认),用户删完之后请求才失败时,`errorMessage` 被算出来又整段丢弃,界面零反馈。丢的不只是失败提示——对账得出的「请确认素材库是否已生成派生图」在同一句里,用户会在毫不知情的情况下重试。改为 `else` 兜底走全局提示。兄弟路径 `split-atlas` 无条件 alert、`remove-background` 直接 rethrow,都不存在这个第三态。
|
||||
- E1 测试并行竞态:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 对进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 做绝对断言 `== 0`,而相邻用例会在自己的作用域里持有两个 guard,Rust 测试默认并行,两者撞上就随机变红。当时改为 before/after 相对断言,但后续第三批确认两次 load 之间仍可被并行用例插入,该方案未修复竞态并已撤回。过期预算用例只应断言 `504`;guard Drop 由相邻独立用例负责。
|
||||
- 验证方法:两条修复都先回退生产代码确认测试变红,再恢复。前端新用例在缺 `else` 分支时超时失败;服务端守卫在去掉任一处标记时报 `left: 3, right: 4`。首次验证时跑错了测试名——`explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 里已有一组同名断言(钉的是 handler 内四处),新加的这组在 `editor_matting_releases_source_buffers_at_oss_boundaries`,两者同名不同域。
|
||||
- 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。
|
||||
- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。E1 当时仅改成相对断言,后续第三批重新打开并完成修正。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素静默路径收口(审查第一批续)
|
||||
|
||||
- 背景:第一批只修了失败尾部那一处静默(`else` 兜底)。复审指出对账分支还有一处早返回同样不说话,追查后发现它有个孪生分支在成功路径上,两处形状一致——操作其实已经落库、用户删掉了占位、没人告诉他素材库多了一份。
|
||||
- 决策:提示与「移除占位」解绑。成功路径 `!result.project` 分支原先把提示写在 `if (hasCanvasGenerationDialogById(...))` 里面,占位不在就整段静默;改为无条件提示、条件移除。对账分支 `!placeholderSurvived` 且本地占位也没了时,不应用快照仍然正确(删除意图胜出),但要补同一句提示——走到这里意味着权威快照里占位已被 completion 消费,本分支下一步正是据此把结果当成功套用,结论一致:结果已落库。
|
||||
- A4 的取舍:会话缓存里剥掉的占位数**不能**直接并入提示计数。完美像素成功后 `applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 不落库,所以会话缓存可能停留在完成之前的版本;重新加载时缓存里那个陈旧占位被剥掉、计数加一,而权威快照其实是成功的,并入就会报一条「上次处理未完成」的假告警。改为单独计数,只在权威加载失败、没有第二个来源可以纠正这幅画面时才提示。
|
||||
- A4 的可观测性核实:`isProjectReady` 只被启动意图消费和自动保存 effect 使用,不参与画布渲染门禁,所以权威加载失败时画布照常显示,用户看到的确实是一张静默少了占位的画布,提示有必要。鉴权失败与项目失访两条路径各自弹窗或跳转,不在这里重复打扰——为此在 `replaceAppHistoryPath` 后补了 `return`,该分支原本就没有后续语句,行为不变。
|
||||
- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。
|
||||
- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时
|
||||
|
||||
- 缺陷:处理预算(30 秒)只覆盖到规整为止,持久化阶段完全无界,仅受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,远超客户端 `snapEditorImageToPixelArt` 的 120 秒。客户端因此会在服务端仍在合法工作时先 abort:对账虽然照常执行(abort 不是 `ApiClientError`,`outcomeMayBePersisted` 为真),但它采样的是一个仍在途的操作——占位还在、`confirm_asset_object` 未跑完所以素材库还空,用户照提示核对什么也看不到,重试就用新 `task_id` 造出孤儿 OSS 对象。
|
||||
- 决策:给持久化整段套独立预算 `EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION = 60` 秒,用第二个 `tokio::time::timeout_at` 包住从 `persist_editor_generated_image_owned` 到 `complete_editor_canvas_generation` 的全部写入。服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内并留 30 秒余量给网络往返与计时精度。
|
||||
- 为什么独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、中途放弃只会留下孤儿对象。取 min 会让「下载越慢、越容易留孤儿」,方向正好反了。
|
||||
- 超时必须带标记:这条超时发生在 PUT 已经发出之后,对象可能已落盘也可能没有,正是 `resultPersistenceStarted` 契约要覆盖的未知结果。不带标记客户端会判成确定失败、直接诱使用户重试。handler 内该标记的钉定计数因此由 4 升为 5,注释同步说明第五处是什么——这个升级由既有守卫自己报出来(`left: 5, right: 4`),不是事后补记。
|
||||
- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
|
||||
- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素孤儿占位判据由结构不变式改为有界时间窗
|
||||
|
||||
- 缺陷:`dropDeadInlineGenerationPlaceholders` 原先依赖一条结构性不变式——置位 `requiresLiveSession` 的占位其收口只能由创建它的会话完成,而活着的那份始终在内存里、永不经过 hydrate,所以从服务端快照读回来的必然属于已死会话。这条在单标签页下成立,多标签页下是假的:B 标签打开同一项目会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以**当前** revision 合法写回,把 A 的占位删掉。
|
||||
- CAS 的作用要说准:它挡的是基于陈旧 revision 的覆盖写。B 在 A 完成**前**写入时 revision 是当前的,CAS 放行——这是有害的那一半;B 在 A 完成**后**写入时 revision 已陈旧,CAS 拒绝——所以「B 把 A 的成品图层写没」这种更严重的情况本来就不会发生。此前 decision-log 笼统写「CAS 兜底」是错的,纠正后也不应反过来说 CAS 完全无用。
|
||||
- 决策:判据改为有界时间窗,只有超过 180 秒才判定为孤儿。窗口上界由两侧共同封死——服务端最坏合法时长是处理 30 秒加持久化 60 秒(都由 `timeout_at` 强制,见同日持久化预算条目),客户端整个 POST 又被 120 秒超时封顶;120 秒之后客户端必已 abort 并把占位改成 `failed` 或移除。取 180 = 120 客户端上限 + 60 余量(网络往返、标签页挂起后的时钟漂移)。
|
||||
- 关键依赖:这个方案在持久化预算落地**之前**不成立。那时服务端最坏时长无界,任何时间窗都是拍脑袋;把最坏时长收进 90 秒之后,时间窗才有硬依据。
|
||||
- 复用既有字段:时间戳用 `generationStartedAt`,由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,已序列化、已 hydrate,无需新增字段。
|
||||
- 撤回先前方案:此前多次记录「真正的修法是给占位加会话归属标识、只允许创建者剥离」。该方案不成立——B 拿到一个不同的会话 id,推不出 A 是死是活,照样只能猜。会话 id 只能识别「不是我的」,不能识别「已经没人要了」。
|
||||
- 兜底方向:缺 `generationStartedAt` 时按可剥离处理。实践中不会出现(两个字段同一次创建一起写),但按「保留」会让这类占位永久留在画布上,按「剥离」最坏只是退回引入时间窗之前的行为。
|
||||
- 时钟:取读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。
|
||||
- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。
|
||||
- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素并发闸竞争路径与 snapper 错误分档补测
|
||||
|
||||
- 背景:审查留下的两处覆盖缺口。并发闸的三条竞争路径(队列满 503 + `retry-after`、等待槽位超时 504、信号量关闭 503)此前零测试——`retry-after` 在整个 crate 里只出现在生产代码一处;`map_editor_pixel_art_snapper_error` 的四档状态码只有 `GridNotDetected → 422` 被间接覆盖。
|
||||
- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——这与当时误以为已修掉、后续第三批才真正纠正的 E1 属于同类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。
|
||||
- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。
|
||||
- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。
|
||||
- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
|
||||
|
||||
- 决策:`/api/external/v1/mcp` 缺少、格式错误或无法验证 Bearer API Key 时继续返回相同 HTTP `401`,并增加 `WWW-Authenticate: Bearer realm="genarrative-external-editor"` 与机器可读 `details.guide`。引导只说明 Bearer Header 格式、登录后在「开发者 API Key」创建密钥、原始密钥只显示一次、凭据不得进入聊天或仓库、配置后重试 `initialize`,以及公开 manifest、Skill 与 OpenAPI 地址。
|
||||
@@ -6026,6 +6176,179 @@
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
|
||||
## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果
|
||||
|
||||
- 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。
|
||||
- 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。
|
||||
- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;原始 record 查询现由收集全部同 ID 记录的 `findCanvasGenerationDialogRecords` 与 `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。
|
||||
- 三态处置:dialog 不存在 → 占位在处理期间被删(本会话或另一标签页),completion 返回 `Ok(None)`,资源与素材已落库但快照不含结果图层,清本地占位并提示素材库,**不套用快照**(此前会套用一份不含结果的快照并写受撤销保护的历史,用户既看不到结果也撤不回);dialog 在且未收口 → 画布确实没收到,只给文案不同步;dialog 在且已收口 → 真成功,套用快照并写 `perfect-pixel` 历史。前两态都保留「用户已删本地占位则删除意图胜出」的检查。
|
||||
- 缺陷二(网关合成响应被当确定失败):分类前提是「拿到 `ApiClientError` ⇒ 服务端明确表态过 ⇒ 结果已知」。该前提对 Pingora 自造的错误体不成立——它只有 `code` / `message`、没有 `details`,因此既不是 transport 异常也拿不到 `resultPersistenceStarted`,直接跳过对账;而 `ConnectTimedout / ReadTimedout / WriteTimedout → 504`、`ErrorSource::Upstream → 502` 都可能发生在 api-server 已完成 OSS PUT 之后。
|
||||
- 决策二:新增 `isGatewayUnknownOutcomeError`,放在 `services/apiClient.ts` 而不是 image-editor——网关在所有接口前面,任何有副作用的 inline 写接口都有同一问题。只收 `GATEWAY_UPSTREAM_ERROR` / `GATEWAY_UPSTREAM_TIMEOUT` / `GATEWAY_PROXY_ERROR` 三类。`GATEWAY_RATE_LIMITED` / `GATEWAY_CONCURRENCY_LIMITED` / `PAYLOAD_TOO_LARGE` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。
|
||||
- 两条共性:都是用代理信号代替事实——用「占位在不在」代替「操作完成没有」,用「有没有 HTTP 响应」代替「应用层有没有表态」。两处都是没有去读被代理的那个事实的真实形状。
|
||||
- 验证:新增四条用例(网关 504 触发对账、网关 429 不触发、应用层无标记 502 不触发、快照无 dialog 时不套用快照)。同时破坏两处修复后,四条精确变红。后两条是对照用例,专门守住「放宽判据不得退回反向谎报」。`vitest src/components/image-editor` 907 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素链路复查:补齐第三处静默分支并区分两条结果文案
|
||||
|
||||
- 背景:对账判据与网关分类修完之后做的整链复查,目标是找遗留与新引入的问题,不是重复已知项。
|
||||
- 遗留(第三处静默):成功路径拿到 `result.project` 后,若本地占位已被用户删除则直接 `return`。不套用快照是对的(删除意图胜出),但什么都不说。此前已修过同一形状的两处(`!result.project` 分支、对账早返回),这是第三处,复查才发现。既有用例 `does not apply a completed project after the perfect-pixel placeholder was deleted` 只断言「不套用」,没断言「要说话」,所以也没挡住。
|
||||
- 新引入(文案混用):上一次修复让「对账发现已收口 + 本地占位已删」这一支沿用了「结果已保存到素材库,画布占位已不存在」。**两种情况的事实不同**——快照里没有 dialog 时服务端 completion 返回 `Ok(None)`,画布上确实没有结果图层;而 dialog 已收口时服务端画布上**有**结果图层,只是本地按删除意图没套用,重新加载即可见。用前一条文案会让用户以为画布上没有,再做一遍,正是本链路要消除的重复创建。
|
||||
- 决策:两条文案抽成常量并按事实分派——`PERFECT_PIXEL_ASSET_ONLY_NOTICE` 用于「服务端画布也没有」,`PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE` 用于「服务端画布已有、本地未同步」。四个使用点各归其位。
|
||||
- 测试补位过程值得记:第一次回归验证只有 1 条变红——说明「对账已收口 + 本地已删」这条分支根本没有用例,文案改动是无覆盖的。补上该用例后再破坏,2 条同时变红。**如果止步于第一次验证,就会把一处无覆盖的改动当成已验证。**
|
||||
- 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*` 走 `is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog` 由 `some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。
|
||||
- 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 inline 占位补到期清理,收掉存活窗口引入的回归
|
||||
|
||||
- 缺陷:存活窗口把「立即刷新也能清掉孤儿占位」这条旧行为换掉了。剥离只在项目首次加载执行一次,窗口内被保留的占位再没有任何东西会重新判定——页面保持打开就一直转,必须等到用户下一次加载且距创建已满窗口才收口。这是引入 TTL 时的已知取舍,本次补上。
|
||||
- 复查中发现的关键事实:**手动收口入口本来就存在**——`requestRemoveCanvasGenerationDialog` 已接入键盘快捷键,对 `generating` 占位会先弹确认再删。所以缺的从来不是删除手段,而是「它已经死了」这个信号;占位看起来和正在干活一模一样。
|
||||
- 决策:加一次性到期定时器,到点走与加载期**完全一致**的处置——移除 + 同一条文案(抽成 `DEAD_INLINE_PLACEHOLDER_NOTICE` 共用)。未采纳「标记 failed 而不移除」:`failed` 会被自动保存持久化,而剥离只处理 `generating`,那张卡片会跨刷新长期存在,与当初选「刷新后占位消失」的用意相反,把一次性噪音变成永久残留。
|
||||
- 三条必须守住的实现约束,都写进了 hook 的文档注释:其一,到期回调只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前 dialogs 和当前时间做——挂上定时器之后占位可能已被拥有者会话正常收口,按闭包旧值行动会清掉一个已完成的占位;其二,清理用底层 `removeCanvasGenerationDialogById` 而不是 View 的 `removeCanvasGenerationDialog`,后者是用户主动删除的语义(写 `delete-generation-result` 历史、清空选中、切回选择工具),自动清理记用户没做过的历史、抢走当前选中态都是错的,加载期剥离同样不做这些;其三,本会话自己在途的占位不会被误清(客户端 120 秒就 abort,catch 会把它推离 `generating`),但不依赖该推理,靠第一条的重新判定兜住。
|
||||
- 作用面划分:`dropDeadInlineGenerationPlaceholders` 跑在**快照**上、只在加载时执行;`collectExpiredInlineGenerationDialogIds` / `resolveNextInlineGenerationDialogExpiryAt` 跑在**内存 dialog** 上、供页面打开期间使用。同一条规则、两种数据形状,边界(到期时刻含等号不算过期)与缺时间戳的兜底方向都保持一致。
|
||||
- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。
|
||||
- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 到期清理链路复查:一个被证伪的假设与它留下的用例
|
||||
|
||||
- 复查怀疑:到期定时器要熬三分钟,而 `canvasGenerationDialogs` 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新状态)。把 effect 依赖挂在数组身份上,看起来会被无关变动不断重挂定时器、永远等不到触发,整个机制静默失效。据此把定时器改挂在计算出的到期时刻上,并加 ref 读取当前 dialogs。
|
||||
- 结论:**假设是错的**。为它写的回归用例(每秒一次无关变动、持续到超窗)在改回数组依赖后仍然通过——数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。
|
||||
- 处置:撤回 `useMemo` + `useRef` 的改动,回到更简单的数组依赖版本——既然简单版本本来就正确,多出来的间接层没有收益。用例保留,但注释改写为它**实际证明**的性质:判定必须留在 effect 体里;将来若把它挪出去(例如只在定时器回调里判定),这条不变式才会真的失效,用例届时会变红。
|
||||
- 记这一条是因为过程本身有价值:先假设、再写用例、用例证伪假设、据此撤回改动。若跳过验证直接保留那次「修复」,就会在没有缺陷的地方永久留下一层多余的间接。本次会话里同类错误(凭推断得出结论而不验证)已出现多次,这次是验证挡住了。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 外部 API skill 文档同步 style 的提示词注入语义
|
||||
|
||||
- 缺陷:`references/requests-and-outputs.md` 把 `style` 描述为「只控制 deterministic post-processing」,缺了本分支给它加的另一半语义——`pixelArt` 会在发给 provider 的提示词末尾追加一行像素风约束。
|
||||
- 归因是文档漂移而非遗漏:`docs/openapi/genarrative-external-v1.openapi.json` 的两处 schema **一直是对的**,连具体子句都写了。master 的 `c00dd099e` 把 `api-selection.md` 拆成四篇新参考文档时是从注入之前的版本重写的,于是 skill 文档退回旧语义,而 OpenAPI 保持正确。
|
||||
- 我上一轮合并时的核查不到位:只 grep 了「`byte-for-byte` 那个错误说法有没有复活」,确认没有就收工。**验证旧错误的缺席不等于验证新事实的在场**,两者要分别查。
|
||||
- 决策:让 skill 文档与 OpenAPI 对齐,措辞不新造。改动限于三行——说明它同时追加提示词子句与启用后处理、`none` 一档不追加也不后处理、`pixelArt` 一档追加一行且是追加而非替换。
|
||||
- 刻意不复制子句字面文本:Rust 常量是真值源,OpenAPI 已复制一份,skill 文档再抄第三份就是把同一事实摊到三处——这次漂移正是这么发生的,只是方向相反。文档改为指向 OpenAPI 并写明「本指南刻意不复制」,让下一个读到的人知道那是有意为之而非遗漏。
|
||||
- 校验面已确认:这批文档由 `external_skill_api.rs` / `external_mcp.rs` 以 `include_str!` 编译期内联,SHA 在运行时从内容算出、测试只断言「算出的与返回的一致」,没有钉死具体摘要,改文档无需同步任何清单。api-server 700 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致)。
|
||||
- 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
## 2026-08-03 到期清理误删本会话在途占位:补归属登记与前置阶段预算
|
||||
|
||||
- 缺陷:到期清理只按 `generationStartedAt + 180 秒` 删除 `requiresLiveSession` 且 `generating` 的占位,区分不出它属于已死会话还是本会话仍在执行。占位在创建后还要走源图解析/直传和 `flushProjectPersistence` 才轮到 POST,而 `snapEditorImageToPixelArt` 的 120 秒**只从最终 POST 开始计**。前置阶段慢起来越过窗口时,定时器会删掉本会话正在用的占位并把删除持久化,随后 POST 因占位不存在返回 `409`;若删除的落库晚于 POST 到达,则 completion 找不到占位返回 `Ok(None)`,结果只进素材库、不落画布。
|
||||
- 我写在 hook 注释里的安全性论证是错的,两条都错:其一「客户端 120 秒就 abort,180 秒时不可能还是 generating」——120 秒不覆盖前置阶段;其二「不依赖该推理,到期重新判定本身兜得住」——重新判定只能识别**已经收口**的占位,识别不出**仍在合法运行**的占位,后者正处于要被删除的那个状态。第二条错得更本质:它给了自己和读者一道并不存在的第二防线。
|
||||
- 前置阶段此前完全无界:直传 `postEditorDirectUploadFile` 是裸 `fetch`、没有 signal,`saveEditorProjectLayout` 的 `requestJson` 没传 `timeoutMs`(同文件其余接口都写了),而 `composeAbortSignal` 在缺失时不设任何默认值。两者各自还有重试(上传最多 3 次尝试、布局保存最多 4 次)。
|
||||
- 决策一(归属登记):`activeInlineGenerationDialogIdsRef` 记录本会话仍在执行的占位 id,创建后**紧挨着**注册(中间不能有 await,否则留出「已存在但未登记」的窗口),`finally` 释放;到期清理跳过其中的 id。到期清理本来就只该针对别人留下的孤儿。
|
||||
- 决策二(整段预算而非逐请求超时):给「占位创建 → POST 发出」整段 40 秒预算。逐个请求加超时的最坏总时长会因重试累加到远超 180 秒窗口,窗口的前提仍不成立;整段封顶后客户端最坏 40 + 120 = 160 秒,落在窗口内并留 20 秒余量。超时抛裸 `Error` 而非 `ApiClientError`,归入未知结果走对账——上传可能已完成、素材可能已落库,正是对账要处理的情形。
|
||||
- 决策三:`saveEditorProjectLayout` 补 `timeoutMs: 60_000`。这是独立缺陷,与本条无关也该修——它被 `flushProjectPersistence` 同步等待在提交路径上,挂住会连带把占位拖过窗口。
|
||||
- 「窗口计时起点应改为 POST 发出时刻」未采纳:归属登记之后窗口不再需要覆盖本会话,只用于跨标签页;而对孤儿占位只有创建时刻这一个可用时间戳,改起点无从实现。整段预算已经让窗口的前提重新成立。
|
||||
- 验证:新增两条用例——本会话持有期间超窗不清理、释放归属后同一超窗占位立即清理。去掉归属过滤后两条同时变红。`vitest src/components/image-editor` 923 通过 / 74 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 归属登记后的链路复查:一处新引入的忙等、两处无界读写、一处过紧预算
|
||||
|
||||
- 复查对象是上一条修复本身,找的是新引入的问题,结果三处新增、一处遗留。
|
||||
- 新引入(忙等,最严重):归属过滤只加在了 `expiredIds`,没加在 `resolveNextInlineGenerationDialogExpiryAt`。被本会话持有的超窗占位不进 `expiredIds`,却仍被算出一个**已经过去**的到期时刻,`delayMs` 塌成 50ms,定时器触发 → tick → effect 重跑 → 状态没变 → 再挂 50ms,变成每 50 毫秒一次 `setState` 的忙等,持续整个持有期。改为先按归属过滤出 `unownedDialogs`,两处判定共用。
|
||||
- 该忙等的可达性不是理论的:前置预算加 POST 之后,catch 里还要做对账 `loadEditorProject`,而归属要到 `finally` 才释放,这段完全可能越过存活窗口。
|
||||
- 遗留(无界读取):`loadEditorProject` 同样没传 `timeoutMs`,而 `composeAbortSignal` 在缺失时不设默认值。它正是对账路径上的读取,挂住会让 catch 迟迟不结束,连带把占位拖过窗口——即上一条忙等的直接助推。补 `60_000`。至此该文件里落在完美像素链路上的三个接口(POST、布局保存、项目读取)都有了显式上界。
|
||||
- 新引入(预算过紧):上一条把提交前置阶段封顶在 40 秒。该阶段在源图是 inline / 未登记时会真的直传一张画布图层,几 MB 的图在较差移动网络下要几十秒,40 秒会把原本能成功的操作改判为失败——**用「无界」换「过紧」同样是回归**。改为 90 秒,并把存活窗口从 180 秒同步提到 240 秒,维持 90 + 120 = 210 < 240 且留 30 秒余量。
|
||||
- 三个常量构成一条跨文件不等式(提交前置预算、客户端 POST 超时、占位存活窗口),任一处被单独调大都会破坏它,后果是跨标签页误删。新增用例把这条不等式连同 30 秒余量一起钉住。本会话自己的占位另有归属登记豁免、不依赖该窗口,所以窗口只需覆盖跨标签页那一侧——这一点也写进了常量注释。
|
||||
- 验证:忙等用例用 `vi.getTimerCount()` 直接断言「不该挂定时器」,把 `resolveNext...` 改回未过滤版本后该用例变红。`vitest src/components/image-editor` 924 通过 / 74 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 补齐 editorProjectClient 超时契约的断言
|
||||
|
||||
- 缺陷:给 `saveEditorProjectLayout` 与 `loadEditorProject` 补超时后,`editorProjectClient.test.ts` 的两条精确参数断言失败。`toHaveBeenCalledWith` 要求参数个数与内容完全匹配,新增第四个参数即不匹配。CI(任务 1645)在 `aa8ea401a` 上报出其中一条,另一条由本地复跑发现。
|
||||
- 处置:更新断言把 `{ timeoutMs: 60_000 }` 写进去,而不是放宽成 `expect.anything()`。超时是契约的一部分——这两个接口一个被 `flushProjectPersistence` 同步等待在提交路径上、一个在未知结果对账路径上,没有上界会把在途占位拖过存活窗口。断言写死之后谁删掉它测试就会红;放宽则等于让刚建立的上界失去看守。已验证:去掉生产代码里的两个超时,两条断言同时变红。
|
||||
- 真正的问题是验证方式而非测试:改的是 `src/services/image-editor/editorProjectClient.ts`,验证却只跑了 `vitest src/components/image-editor`,改动面与验证面完全对不上。这个盲区在本次会话中期分析另一份 CI 日志时已由我自己指出过,却没有改掉习惯,于是同一个盲区再次漏出——而且这次不是难复现的跨文件竞态,是本地一跑就红的确定性失败。
|
||||
- 约定:这条链路横跨 `src/components/image-editor/` 与 `src/services/image-editor/`,往后验证至少同时覆盖两处。不跑全量套件——本机有九条稳定的环境失败(符号链接、`0600` 权限模式、缺客户端 AppData 配置),噪音大于收益。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 对账整段设界,并把提交前置预算变成真正的取消
|
||||
|
||||
- 缺陷一(对账可被素材库读取永久挂住):对账用 `Promise.all` 同时等项目快照与 `refreshAssetLibrary`,而 `loadEditorAssetLibrary` 至今没有 `timeoutMs`(`composeAbortSignal` 缺失时不设默认值)。`.catch()` 只接住拒绝、接不住永不 settle;catch 体内的 await 不返回,`finally` 就永远不执行——占位归属登记与源图层锁都释放不掉,而到期清理又豁免已登记的占位,页面永久停在 `generating`,同一源图也无法再次操作,刷新前无解。
|
||||
- 这个洞是上一条修复留下的:给 `loadEditorProject` 加界时写的注释已经把机制说对了(「挂住会让 catch 迟迟不结束」),却只给同一个 `Promise.all` 里两个 await 中的一个加了界。逐个接口补超时这条路已经漏过一次。
|
||||
- 决策一:给**整段对账**设 75 秒上界,而不是继续逐个接口补。往对账里加任何新的 await 都自动受约束。超时必须**解析为 null 而不是拒绝**——这段代码本身位于 catch 内,抛出会穿出整个 async 函数,而调用方是 `void snapSelectedLayerToPerfectPixels(...)`,结果是未处理的 rejection;解析为 null 则落进既有的「权威项目快照读取失败」分支,语义正好一致。
|
||||
- 缺陷二(预算只停止等待、不取消):`withPerfectPixelPrePostBudget` 原先只是 `Promise.race`,超时后底层继续跑。直传 `fetch` 没有 signal,被放弃的上传会一路走到 confirm 并注册对象,用户重试再产生一份。
|
||||
- 决策二:把同一个 `AbortSignal` 贯穿凭证请求、直传 POST 与 confirm 三步,由前置预算到期时 `abort`。只中止直传会留下未 confirm 的 OSS 对象,只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。`requestJson` 从 `init.signal` 取信号并与自身超时合成,所以凭证与 confirm 只需在 init 里传入。
|
||||
- 未采纳评审建议的全量贯穿(再覆盖重试等待与 flush/save):那要再动两个模块,而收益只是少产生一些用户不可见的存储孤儿;`flushProjectPersistence` 现已有 60 秒上界,最多多挂 60 秒后自行结束。改动面从四个模块降到两个,绝大部分收益保留。
|
||||
- 后果分级要说清:缺陷一是永久性的 UI 卡死,缺陷二只是存储层孤儿对象(confirm 注册的是 asset object,不是素材库条目,用户基本不可见)。两者同为 P2 但不同量级。
|
||||
- 验证:新增用例让项目快照读取永不 settle,断言 120 秒后完美像素状态回到「空闲」——即 `finally` 确实执行。去掉对账 deadline 后该用例变红。`vitest src/components/image-editor src/services/image-editor` 974 通过,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素第一批:稳定 operation 与单事务数据库提交
|
||||
|
||||
- 被纠正的旧边界:完美像素原先在 OSS PUT / HEAD 后顺序执行 asset object confirm、project resource、账号素材与 canvas completion 四次独立数据库调用。任一后段失败或本地 timeout/drop 都可能留下可见的部分事实;重试又使用随机 task / object / resource / asset ID,无法把同一次逻辑操作识别为重放。历史 decision 条目保留为当时证据,本条取代其“继续沿用非事务顺序”的现役结论。
|
||||
- operation identity:规范化 `canvasCompletion.dialogId` 即 operationId,由 owner + project 共同限定作用域。`taskId = pixel-art-snap-{operationId}`,让响应丢失时的客户端无需服务端回包即可计算;asset object、project resource 与账号素材 ID 用 `SHA256(editor-pixel-art-result-v1 + owner + project + operation + record kind)` 的前 16 bytes 稳定派生。请求 fingerprint 为 64 位 SHA-256,覆盖来源 object key、来源和输出字节摘要、来源资源、素材类型、规范目录 / 标签、canonical generationInputs、canvas completion 与算法版本;最终 OSS object key 必须携带该 fingerprint。
|
||||
- 原子边界:OSS PUT / HEAD 仍在事务外。验证上传后,`persist_editor_pixel_art_result_and_return` 受 editor generation runtime service identity 保护,在一次 `try_with_tx` 中校验并写入 asset object、project resource、editor asset,并在同一事务内读取最新 canvas、按当前 revision 完成 dialog。handler 禁止在 procedure 前调用旧 `confirm_asset_object`、resource、asset 或 completion helper。该保证只覆盖这四类结果事实;前置 owner-scoped 项目 / 素材读取仍可能沿用既有 `ensure_default_canvas / ensure_default_asset_folder` 懒建基础记录,不宣称整个 preflight 对数据库零写入。
|
||||
- 重放与冲突:三条稳定记录完整且业务内容相同才返回 `AlreadyApplied`,重放不得再次执行 layout CAS 或推进 revision;任一稳定 ID 指向不同内容、同 object location 被其他 ID 占用、同 operation 输入漂移或三条记录只有部分存在都失败关闭并映射 HTTP `409`。时间字段不参与 exact replay 内容比较。权威 dialog 已删除时三条记录同事务提交、canvas / revision 不变并返回 `DialogMissing`。
|
||||
- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。
|
||||
- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。
|
||||
- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。
|
||||
- 2026-08-03 前端 hydrate 收口边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素第二批:object-only 上传与 GET-only unknown 收口
|
||||
|
||||
- 上传边界:inline 源图不再调用会在 confirm 后继续换签的完整上传 helper,只执行 `ticket → OSS PUT → confirm → objectKey`。完美像素在创建占位后立即确定 dialog / operation ID,并把它作为稳定 upload ID;源 fetch、图片解析边界、ticket、PUT、confirm 共用前置预算的同一个 `AbortSignal`。完整 helper 的 signed URL 调用也防御性透传 signal。这样 confirm 成功后没有新的换签失败窗口,同一 operation 的内部重试也不会换对象路径。
|
||||
- verdict 边界:POST 成功不再直接采用响应体的 `project`,POST 中的 `asset` 也只有在项目 GET 已确认终态且 response task / resource 与 GET resource 一致时才允许本地 upsert。项目 GET 是唯一 verdict 来源;匹配 task resource + 已收口 dialog/layer 为画布成功,无 dialog + 匹配 task resource 为 asset-only,resource 已出现但 dialog 仍 generating 继续等待,无 dialog 且无匹配 resource 也继续等待。重复匹配 resource 或已收口 dialog 与 resource / layer 不一致失败关闭为 conflict,不猜测成功。
|
||||
- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journal;hydrate 会先把可接受的未来 `submittedAt` 规范化到当前时间,再把截止收紧到规范化提交时间和当前时间各自允许的 75 秒上限,并在下一次布局持久化时写回仍可再次 hydrate 的收紧形状。
|
||||
- identity 与删除:unknown 保留原 dialog 上的完整 `perfectPixelOperation`,人工重试原样发送持久化 request;普通按 ID 删除和随源图层删除均保留未收口 durable operation。对话框删除入口会激活原占位并提示继续核对 / 原样重试;Delete 快捷键若只命中受保护 operation 则在写历史、清选择或执行副作用前完整 no-op,混合选择只统计并删除其它可删除目标。刷新恢复只做 GET,owner / project 切换或卸载会取消旧观察。完全没有 operation journal 字段的 legacy inline 占位仍沿用既有 TTL;字段存在但损坏时保留失败关闭标记,不能降级成可清理的旧占位。
|
||||
- 投影刷新:`refreshAssetLibrary` 只在项目终态后 best-effort 触发,并同时吞掉同步 throw 与异步 reject;永不 settle 的刷新 Promise 也不参与 await,因此不能阻塞项目应用、提示或 `finally` 解锁。
|
||||
- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它文档清理在本次第二批提交时尚未纳入,后续由下一条第三批完成。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素第三批:移除全局 Atomic 相对断言并完成文档收口
|
||||
|
||||
- 竞态根因:过期预算用例先读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH`,再在断言前读取一次;相邻 Drop 用例可在两次 load 之间创建或释放 guard。相对 before/after 与绝对 `== 0` 一样没有跨测试隔离,Rust 默认并行时仍会随机失败。
|
||||
- 测试边界:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 只构造过期 deadline 并断言 `504`,不再观察全局队列深度。`pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop` 继续作为独立 Drop 契约用例;未引入 `--test-threads=1`、全局串行锁或其它掩盖手段。
|
||||
- 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。
|
||||
- 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt`、`npm run check:encoding`(5153 个文件)与 `git diff --check` 通过。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 inline 占位 ownership 释放改为可观察信号
|
||||
|
||||
- 覆盖缺口:2026-08-03 的“释放归属后同一超窗占位立即清理”测试在 `rerender` 时同时创建了新的 dialogs 数组和 callback,effect 实际由这些依赖变化唤醒;它没有证明 `finally` 里单独执行 `Set.delete()` 会重新判定。生产实现把稳定 ref 对象放进依赖,但 React 不观察 `.current` 内容变化,因此旧测试与旧实现之间存在同一个盲区。
|
||||
- 决策:Set 继续作为首个 await 前同步可见的 ownership 真值,但封装进 `useInlineGenerationPlaceholderOwnership`,不再向 View 暴露可变 ref。`claim / release` 只有在 membership 真变化时才推进 version;到期 effect 同时依赖稳定 `has` 和 version。首次提交与人工 exact replay 通过同一份 ownership 登记 / 释放,hydrate GET-only 恢复只查询这份 ownership 来避开本页 live Promise,不能在 View 创建第二份 registry。
|
||||
- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。
|
||||
- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint`、`npm run check:encoding` 与 `git diff --check` 通过。未追加其它测试或全量测试套件。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 项目快照对账不再按重复 dialog ID 首项短路
|
||||
|
||||
- 缺陷:项目快照查询 helper 只返回第一个同 ID generation dialog。legacy / External API 画布若含重复 ID,首条已收口、后条仍 unresolved 时,通用 queued completion 会漏掉用于收口的第二次 GET;完美像素还可能把不满足后端唯一性契约的快照误判为成功。
|
||||
- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。
|
||||
- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素对账读取改用请求全生命周期绝对截止
|
||||
|
||||
- 缺陷:`loadEditorProject` 的 `timeoutMs` 只包住业务 `fetch` 等响应头。缺 token 时的登录恢复发生在该 timer 建立前,401 后的共享 refresh 等待也不受它限制;收到响应头后 timer 已清理,成功和错误响应的 `response.text()` 又可继续永久挂起。任一环节不 settle,完美像素对账都无法重新检查 75 秒窗口,首次提交的 dialog ownership 与图层锁也无法进入 `finally` 释放。
|
||||
- 决策:`requestJson` 新增 opt-in `deadlineAt`,从函数入口建立一次 lifecycle signal,覆盖缺 token 补票、业务 fetch、401 refresh 等待、所有 GET attempt、退避和成功 / 错误响应体读取。等待共享 refresh 只取消当前调用者,不把该 signal 传入共享 refresh 请求,避免一次图片对账超时取消 AuthGate 或其它请求正在复用的刷新;signal 到期也不得被鉴权 catch 吞掉后继续发业务请求、清 token 或广播登录态变化。未传 `deadlineAt` 的调用保持既有 `timeoutMs` 行为。
|
||||
- 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。
|
||||
- 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素 confirm 后 strict 保存失败保留原 operation
|
||||
|
||||
- 缺陷:源图 `ticket → PUT → confirm` 成功后,首次 strict layout flush 仍沿用从上传开始计算的 90 秒预算。预算耗尽会把占位标成普通 `failed` 并解锁;既有重试只接受 `pending-confirmation`,普通按钮遂创建新 dialog、重新上传并换 operation identity。旧 confirmed source object 无法再由用户路径引用。
|
||||
- 决策:源准备 90 秒与 operation journal strict save 60 秒拆开。confirm 后立即形成稳定 `perfectPixelOperation`,strict deadline 覆盖等待活动保存、PATCH、revision conflict reload 和 transport retry;到期会取消 strict request/waiter、阻止自动转移或继续 POST,并释放本地活动保存槽。浏览器 abort 不等于远端撤销,迟到 PATCH 仍可能提交,但其本地 Promise 不再触发 POST;后续重试依靠 revision CAS/reload 收口。
|
||||
- 状态与重试:POST 尚未发出时的 strict 失败保留 `failed + perfectPixelOperation`,不做结果 GET;原占位提供 exact retry,复用同一 request、source objectKey、dialog/operation/task identity,不重新执行 ticket、PUT 或 confirm。普通完美像素入口把该状态视为未收口,不能创建第二条 operation;普通删除和随源图删除同样保留 identity。
|
||||
- 预算不变式:源准备 90 秒加 strict journal 60 秒仍小于 legacy inline 占位 240 秒窗口;POST 只会在 operation journal ACK 后开始,因此不再计入该 legacy TTL。布局保存本身使用请求全生命周期绝对 deadline,覆盖鉴权等待、业务 fetch 与响应体读取。
|
||||
- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。这里的 object-only 是指 OSS 中已有私有源图文件、数据库也已有对应 `asset_object / objectKey` 登记,但尚无 `perfectPixelOperation` journal、项目 resource、素材库 asset、完美像素结果或画布结果图层。用户界面不可见且刷新后无法复用该 identity,再次点击可能重新上传;影响限于不可达的源图存储与垃圾记录累积,不代表结果已生成、重复扣费、越权或数据泄露。
|
||||
- 本 PR 的修复边界到此为止:只保证 operation 已形成后,strict 保存失败或超时不会丢失 identity、不会重新上传,并且迟到 PATCH 不会继续触发 POST;不继续引入服务端 durable upload journal、上传 reservation、孤儿对象扫描/回收或历史数据清理,也不宣称撤销已发送的 PATCH。彻底消除上述崩溃窗口需要独立设计、评审和交付,不作为本 PR 的合并阻断项。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 generation 占位右键删除复用统一请求保护
|
||||
|
||||
- 缺陷:快捷键删除已在写历史前过滤未收口完美像素 operation,但 generation 占位的右键菜单仍直接调用低层 `removeCanvasGenerationDialogById`。低层会保留受保护 operation,上层却已写入 `delete-generation-result` 历史、清空选择并关闭交互,形成“占位未删但出现伪历史和 UI 副作用”的不一致;普通 generating dialog 也会绕过既有删除确认。
|
||||
- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。
|
||||
- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素最终 PNG 在 PUT 前执行只读 preflight
|
||||
|
||||
- 缺陷:最终 PNG object key 虽已稳定,目录、画布 completion 和布局大小门禁仍只在 OSS PUT / HEAD 之后的原子 procedure 内判定。可预知的自定义目录缺失、目录越权、重复 dialog 或 2 MiB / 512 KiB 布局拒绝会先产生无引用 OSS object,再返回确定失败。
|
||||
- 决策:上传 helper 拆成纯 prepare 与 execute。prepare 只生成精确 object key / request,不访问 OSS;handler 用同一 object key 构造候选 project resource,调用受 runtime service identity 保护的只读 `preflight_editor_pixel_art_result_and_return`。preflight 允许尚未创建的默认目录,要求自定义目录存在且属于 owner,复用 `plan_editor_pixel_art_canvas_completion`,并对 legacy / structured 结果布局执行 2 MiB 总量与 512 KiB 单项门禁。通过后才执行 PUT / HEAD,再调用既有原子 persist。
|
||||
- 预算与 unknown 边界:preflight、PUT / HEAD 和最终 persist 共用既有 60 秒绝对 deadline。preflight 失败或超时发生在第一次 PUT 之前,不带 `resultPersistenceStarted`;从第一次 PUT 发出开始继续沿用 unknown 标记和项目 GET 对账。
|
||||
- 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。
|
||||
- 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影
|
||||
|
||||
- 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。
|
||||
@@ -6114,3 +6437,140 @@
|
||||
- telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScript:hidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。
|
||||
- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType
|
||||
|
||||
- 背景:结构化画布保存要求图层布局项里的资源权威字段与 `editor_project_resource` 行逐字相等,否则整次 PATCH 报「与项目资源不一致」,而该 400 属于 non-retryable,会被前端保存队列静默吞掉。但读边界并不把这些值原样下发:`sanitize_editor_user_model` 会脱敏内部处理模型、`provider` 被无条件省略(见 2026-07-31 修正抠图内部元数据的普通用户读取边界),`sourceType` 则在结构化保存校验通过后被归还资源行、图层列置空,读回时整个键不存在。客户端拿不到权威值只能自己补——`resolveHydratedLayerModel` 沿来源链推导出展示用生图模型,`hydrateLayer` 把缺失的 `sourceType` 猜成 `uploaded`——再原样回写,判等于是必然失败。前者命中含 2026-07-30 之前抠图派生资源的画布,后者命中所有 generated 图层;两者都在项目重新加载后的首次保存触发,用户侧表现为「改动悄悄没保存」,完美像素因为提交前是严格保存才把服务端原文暴露出来。
|
||||
- 决策:被读边界脱敏或不下发的字段,一律以资源行为准,客户端不参与回写。`serializeLayer` 对**挂着项目资源行**的图层(`resourcePersistenceState === 'registered'`)不再输出 `model` / `provider`;缺资源行的自包含 legacy 本地图片序列(角色动画逐帧层等)必须继续输出——服务端 `normalize_structured_canvas_layer_against_resource` 对 `resource == None` 走早退分支,只摘 `assetKind` 就把 item 原样写回,`item_json` 是这类图层元数据的唯一存储,停发会让模型信息在下一次保存后永久丢失。`normalize_structured_canvas_layer_against_resource` 对这两个字段改为直接丢弃而不判等——它们属于纯丢弃字段,判等通过与否都不写回资源行(区别于会合并回资源的 `assetKind` / `generationInputs`),放宽不影响任何持久化状态。`sourceType` 属于意外丢失而非有意脱敏,改为在读边界按图层自己声明的 `resourceId` 回填权威值,口径与既有 `objectKey` / `assetObjectId` 一致;客户端 `hydrateLayer` 同时把缺键回落到资源值作为兜底,不再猜 `uploaded`。
|
||||
- 不变式:凡是 owner 读边界会脱敏或省略的图层字段,写边界不得对其判等;凡是写边界要判等的图层字段,读边界必须原样下发或可由资源行回填。改动任一侧时必须同时检查另一侧,只改一侧即构成本条缺陷的复发。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorModel.ts` 的 `serializeLayer` 与 `hydrateLayer`、`server-rs/crates/api-server/src/editor_project.rs` 的 `EditorPayloadMediaReference` 与 `sanitize_editor_payload_media_value`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs` 的 `normalize_structured_canvas_layer_against_resource`。不修改 SpacetimeDB schema、迁移或绑定,不改动历史数据,不改变对外契约。
|
||||
- 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。
|
||||
- 验证方式:前端覆盖已登记资源的图层产物不含 `model` / `provider`、自包含本地序列仍保留并可往返,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错、读回时 `sourceType` 键确实被丢弃、以及显式冲突的 `sourceType` 仍失败关闭。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废
|
||||
|
||||
- 背景:2026-08-04「完美像素第二批:object-only 上传与 GET-only unknown 收口」把「未收口的完美像素 operation」定为不可删除,理由是结果 unknown 时要保留 identity 供原样重试;同日「confirm 后 strict 保存失败保留原 operation」又把 POST 尚未发出的 `failed` 一并纳入,理由是从源图重开会重新执行 ticket / PUT / confirm,让上一次已 confirm 的源图对象失去引用。两条叠加后,右键删除、Delete 快捷键、随源图层删除、多选删除全部豁免该占位,到期清理也不覆盖它,用户画布上出现了删不掉的元素。
|
||||
- 缺陷判定:删除占位**不撤销任何在途请求**——完美像素没有取消接口,结果照常落库并进素材库;服务端 completion 发现 dialog 已不在会返回 `DialogMissing`,客户端本就有对应提示。封锁买到的只是「结果自动回填画布」这一便利,代价却是用户文档不可编辑。至于孤儿源图对象,「confirm 后 strict 保存失败保留原 operation」自己的「剩余边界」一节已把同类残留定性为「影响限于不可达的源图存储与垃圾记录累积……不作为本 PR 的合并阻断项」;为避免同一种残留而禁止用户删除自己画布上的元素,权衡不自洽。
|
||||
- 决策:本条取代上述两条中关于**删除**的条款。完美像素占位在 `generating` / `pending-confirmation` / `failed` 任何状态都可删,且不弹确认。低层 `removeCanvasGenerationDialogById` 恢复为无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因;随源图层删除不再豁免;Delete 快捷键与多选删除不再过滤该目标。删除确认的判据收敛为具名的 `requiresGenerationDeleteConfirmation`:现成弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立,而完美像素 `generation_cost_mud_points = 0`。运行时标记 `perfectPixelOperationInvalid` 时必须同时丢弃 `perfectPixelOperation`,与 `hydrateCanvasGenerationDialog` 口径一致,不再留下「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。
|
||||
- 保留不变:保留 operation identity 供「在原占位原样重试」的能力不变,重试仍复用同一 request、source objectKey 与 dialog / operation / task identity。从源图重新发起仍被 `existingOperation` 闸拦住,另行处理。带 operation 的占位继续豁免 inline 占位 TTL 到期清理——用户主动删除与系统替用户删除是两回事。
|
||||
- 已知后果:删掉 `pending-confirmation` 占位后,刷新恢复不再对账这条 operation;结果若已生成只会出现在素材库,不回填画布。这是用户主动放弃的结果,不是回归,不得据此判定为缺陷。
|
||||
- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。
|
||||
- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除
|
||||
|
||||
- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。
|
||||
- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再让客户端拒发 POST。(**更正**:本条原文写作「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」,不准确——服务端要求占位此前已持久化,占位从未落库时 POST 会被 409 拒收。被解除的是「客户端在拿到 revision ack 前拒发」这一**客户端侧**阻断,不是端到端依赖;见 2026-08-05「pre-POST flush 是服务端硬前置」。)严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence` 的 `requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。
|
||||
- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。
|
||||
- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。
|
||||
- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。
|
||||
- 影响范围:新增 `perfectPixelOperationStore.ts`;`ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId`;`ImageCanvasEditorModel.ts` 的 `serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders`;`useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect;`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。
|
||||
- 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 账本寿命短于标记寿命:收口态不需要账本,孤儿账本不看对账窗口
|
||||
|
||||
- 背景:上一条把请求账本移到本机后引入了一条判据——「布局里有 `perfectPixelOperationId` 标记、本机没有账本 ⇒ 该占位无效」。这条判据按构造就是错的,因为两者寿命根本不对称:标记写进布局后**寿命无限**(服务端完成 completion 时只做字段级改写,置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清 `errorMessage`,从不摘掉标记,见 `editor_project_storage.rs` 的 `plan_editor_pixel_art_canvas_layout`),而账本**寿命很短**(收口即清、75 秒对账窗口、7 天保留期、换设备即无)。账本消失是正常终态,不是异常。同一个不对称还以第二种形态出现在恢复 effect 里:孤儿账本按 `reconcileUntil` 短路。
|
||||
- 缺陷一(每一次成功都被判成失败):`settleLivePerfectPixelVerdict` 在 applied 终态先清账本,紧接着 `applyProjectSnapshot` 用真实 hydrate 重新套用权威快照;此时布局里标记还在、账本已清,于是成功结果被判成 `failed + perfectPixelOperationInvalid`,用户点开刚生成的图层会看到「操作快照无效」。更糟的是这个状态会被下一次自动保存序列化回服务端,覆盖服务端正确的 `idle`;`perfectPixelOperationInvalid` 一旦落库,此后单凭它就能强制 `failed`,**自我固化**。历史上早已完成的完美像素占位(当时带内联账本)在标记化改写后同样中招。
|
||||
- 缺陷二(兜底分支在唯一目标场景下失效):孤儿账本对账是「删掉严格布局保存仍然安全」的全部依据,目标场景是「POST 已发、布局没落盘、浏览器关闭、稍后重开」——而重开几乎必然晚于 75 秒对账窗口,`operation.reconcileUntil <= now` 的短路让这条分支基本永不生效,条目还会在本机躺满整个保留期反复被跳过。
|
||||
- 决策一:凡是「缺账本 ⇒ 无效」的判据,一律先排除**收口态**。收口的定义复用既有的 `isUnresolvedCanvasGenerationDialogRecord` 取反:带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`。收口态占位不需要账本——`generatedLayerId` 本身就是服务端已回填的证据;它同时**无条件忽略**已落库的 `perfectPixelOperationInvalid` 标记与残留 `errorMessage`,并把状态强制归位到 `idle`,让被上一版写脏的行在下一次 hydrate 时自愈。写边界同步收窄:`serializeDialogReferences` 对收口态占位不再输出 `perfectPixelOperationId`,让标记的寿命与账本对齐,不再在布局里堆积。
|
||||
- 决策二:孤儿账本**不按 `reconcileUntil` 短路**。`reconcileUntil` 的语义是「结果可能还在飞,值得多读几次」,而孤儿是上一个会话留下的、发出它的标签页早已不在,需要的是一次能回答「到底落没落」的确定性读,不是轮询。为此新增 `readPerfectPixelOrphanVerdict`(单次 `loadEditorProject` + `inspectPerfectPixelProjectSnapshot`),不复用 `reconcilePerfectPixelProject` 的轮询循环——后者在窗口耗尽时确实会因 `hasAttemptedRead` 初值为 false 而强制读一次,但那是实现副作用而非契约,寄生在上面迟早被重构静默破坏。收口口径:`dialog-missing` → 刷新素材库 + asset-only 提示(这正是布局尽力保存失败的典型结局);`applied` → 静默刷新素材库(本次读到的就是当前项目的权威状态,结果本就在用户眼前,弹提示只是噪音);`pending` / `conflict` → 完全静默(什么都没落库,用户无需知道)。三者都清账本;**读失败不清**——那是「不知道」而非「知道没有」,留给下次加载。
|
||||
- 保留不变:未收口占位在账本缺失时仍然收口成可删除的失败态,上一条决策的「缺失只降级、不得构成阻断」原样有效。本条只是把「缺失」的适用范围限定在它本来就该管的那一半。
|
||||
- 同类判据的通用要求:本仓库中任何「持久化标记 + 短寿命本地状态」的组合,判据都必须先问「这个标记所指的事情是不是已经结束了」。只要标记比它依赖的状态活得久,`marker && !state ⇒ invalid` 就一定会把正常终态误判成异常。
|
||||
- 影响范围:`ImageCanvasEditorModel.ts` 新增 `isSettledPerfectPixelDialogRecord` 并改写 `serializeDialogReferences` / `hydrateCanvasGenerationDialog`;`useImageCanvasGenerationWorkflow.ts` 新增 `readPerfectPixelOrphanVerdict` 并改写恢复 effect 的孤儿分支。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。
|
||||
- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 pre-POST flush 是服务端硬前置,对账窗口改在 flush 之后锚定
|
||||
|
||||
- 事实更正:`server-rs/crates/api-server/src/editor_project.rs` 的 `validate_editor_pixel_art_snap_placeholder_exists` 在处理前检查占位是否**已经持久化**到项目布局;既没有同 ID 的已持久化 dialog、也没有同 operation 的稳定 resource 时返回 **409**。因此 POST 前那次 `await flushProjectPersistence` 不是可省的画布同步,而是服务端硬前置,不能简单取消。上一条决策里「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」的说法就此更正:准确表述是——**占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,因此客户端无法证明该前置条件已经满足,只能提高满足它的概率**(占位可能已被此前的 450ms 自动保存落库,PATCH 也可能成功而 ACK 丢失)。被解除的是客户端侧「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。
|
||||
- 缺陷:首次提交与人工 exact retry 都在这次 flush **之前**就算好 `submittedAt / reconcileUntil`。该 flush 没有整体上限(单次 PATCH 60 秒 × 最多 4 次尝试,且 flush 的等待循环会清掉退避定时器立刻重跑),慢保存足以在 POST 发出前烧光整个 75 秒窗口,请求带着已过期的 reconciliation deadline 发出,对账退化成「强制读一次即以 pending 收尾」。
|
||||
- 决策:窗口一律锚在 POST 发出的时刻。flush 返回且 authority 复核通过之后,调用 `createPerfectPixelReconciliationOperation` 重新设置 `submittedAt = 当前时间`、`reconcileUntil = 当前时间 + 75 秒`,按同一 `operationId` 覆盖本机账本与 dialog,并登记新的 recovery key(key 含 `reconcileUntil`),随后立即 POST。只覆盖时间字段:`request` 与 dialog / operation / task identity 逐字节不变,也不产生第二条账本。
|
||||
- 为什么保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,此时服务端已有带 `perfectPixelOperationId` 的占位,而 localStorage 跨标签共享——本机若还没有账本,那条占位会被直接 hydrate 成 `failed + invalid`。预写的 provisional 账本正好堵住这个可长达数分钟的窗口,因此采用「预写 + flush 后重新锚定」,不采用「把首次账本写入整体挪到 flush 之后」。
|
||||
- 人工 exact retry 同此口径:flush 之前继续沿用旧 operation(UI 可以先切到 `generating` 让用户看到重试已开始),flush 完成、authority 复核通过后才重新锚定、覆盖账本与 dialog,然后 POST。先刷新窗口再等 flush 等于把窗口烧在等待上。
|
||||
- 明确不在本次范围:flush 本身的无上限等待,以及删除 `strictCompletion` 后每次 PATCH 重起 60 秒 deadline 的连带效果。既然等待是硬前置,给它加上限只会把「慢」换成「409 失败」,不构成改善;真要治需要服务端接受「占位随请求一起提交」,属于接口契约变更。
|
||||
- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的 `snapSelectedLayerToPerfectPixels` 与 `retryPerfectPixelOperation`。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 同步更新的文档:本文件上一条的错误声明已就地更正;`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 中「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」「未收口 operation 不可删除、不写 delete-generation-result 历史」「durable operation 不得被普通删除路径清理」等已被近几个提交推翻的条款一并修正。历史 commit message 只能靠重写 Git 历史才能改动,不为此改写历史,以本条追加说明为准。
|
||||
- 验证方式:两条受控时钟用例分别覆盖首次提交与 exact retry——让 pre-POST flush 期间时钟前进 90 秒(超过整个 75 秒窗口),断言 POST 那一刻账本里是刚建立的完整 75 秒窗口、`submittedAt` 等于 POST 时刻、账本仍只有一条、`taskId` 与 `request` 逐字节未变;retry 用例另断言 flush 期间 dialog 上挂的仍是旧 `submittedAt`,证明窗口没有被提前刷新。两条用例均已实证:回退修复后报 `expected 1800000000000 to be 1800000090000`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 专题文档补齐同步:账本位置、窗口锚点、删除权与孤儿对账
|
||||
|
||||
- 背景:近五个提交连续翻转了完美像素的多条前端契约,但只追加了 decision-log。`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 是这些条目自己声明的「关联文档」,其中仍写着已被推翻的旧契约,实现与验收依据互相矛盾——按旧文档做验收会把当前正确行为判成缺陷。
|
||||
- 已更正的条款:①「请求快照写入占位并 flush 布局」→ 账本写本机 `perfectPixelOperationStore`,布局只留 `perfectPixelOperationId` 标记;②「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」→ 通道已删除,改为 best-effort flush,并写明服务端要求占位此前已持久化(否则 409)、客户端无法证明该前置只能提高概率;③「`submittedAt / reconcileUntil` 从快照写入起算」→ 从 POST 发出时刻起算,首次提交与人工重试同口径;④「POST 前必须取得布局保存成功确认,否则 POST 为零」→ 保存失败不再让 POST 为零;⑤「未收口 operation 不可删除、不写 `delete-generation-result` 伪历史」与「durable operation 不得被普通删除路径清理」→ 任何状态可删且不弹确认,确认只对计费生成成立;TTL 豁免(系统不替用户删)与用户主动删除是两回事。
|
||||
- 新增到文档的不变式:标记与账本寿命必须对齐——收口态占位不再写出标记,也不得因「有标记、没账本」被判无效;账本读不到时只有**未收口**占位收口成可删除失败态。恢复必须覆盖孤儿账本,孤儿走一次确定性的读而非轮询,不按 `reconcileUntil` 短路,三种结论的提示口径与清账本规则一并写明。
|
||||
- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。
|
||||
- 影响范围:仅文档。不改代码、不改测试。
|
||||
- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。
|
||||
|
||||
## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制
|
||||
|
||||
- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。
|
||||
- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。
|
||||
- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。
|
||||
- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。
|
||||
- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。
|
||||
- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。
|
||||
|
||||
## 2026-08-05 孤儿账本只在正向终态清除;legacy 迁移判据对齐 exact retry
|
||||
|
||||
- 缺陷一(孤儿过早清账):上一条把孤儿改成「读到任何结论就清账本」,但 `pending` / `conflict` 不是结论。浏览器关掉不会中止服务端处理——api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能还在跑;重开项目时单次 GET 没看见 resource 只说明「还不知道」。此刻清账,服务端稍后落库便再无对账凭据,用户永远等不到「结果已进素材库」的提示,同时又多开一个 identity 的口子。
|
||||
- 决策一:只有 `applied` / `dialog-missing` 这两个**正向终态**才清账本;`pending` / `conflict` 与读失败一律保留,留给下次加载重读。残留由 7 天保留期与 32 条上限兜住,代价是极少数永不落库的条目每个会话多一次 GET——比丢失凭据便宜得多。
|
||||
- 缺陷二(legacy 迁移漏 `failed`):迁移判据按状态白名单列举了 `generating` / `pending-confirmation`,漏掉 `failed + perfectPixelOperation`。那是旧严格保存失败的合法持久化形状(请求已备好、POST 从未发出),`retryPerfectPixelOperation` 明确接受该状态,面板上的「重试同一完美像素操作」也正是在这个形状下出现。漏迁的后果与缺陷本体一致:下一次保存剥成 marker 后再加载即 `failed + invalid`,重试按钮消失,用户只剩删掉重做——而那正是新 identity,正是 exact retry 存在的意义所在。
|
||||
- 决策二:迁移判据改用 `isUnresolvedCanvasGenerationDialogRecord` 取反,与 `hydrateCanvasGenerationDialog` 判定收口态用的是同一个函数,两处不会漂移。**通用要求**:涉及「这条 operation 还需不需要账本」的判断一律问「它收口了没有」,不要列举状态——状态白名单会随着新增状态或语义变化而静默漏项,本条就是实例。
|
||||
- exact retry 的价值必须记清楚,它不是「省一次操作」:原样重放同一 identity 时服务端幂等生效(稳定 task / object / resource / asset ID 由 `owner + project + dialogId` 派生,请求带 fingerprint),同内容重放返回 `AlreadyApplied` 而不是再造一份。删掉它意味着对账查不出结论时用户只能新建 identity,旧的若其实成功就会重复。曾评估过「直接删除该功能以减少用户困扰」,权衡后保留——困扰来自文案与状态不清晰,可以单独治理,而幂等保证一旦删掉无法用文案补回。
|
||||
- 同步更正的文档:专题文档三处残留矛盾——`submittedAt / reconcileUntil` 仍称「从稳定请求快照写入时建立」(应为 pre-POST flush 之后、POST 之前)、仍称「不会让布局校验失败升级成硬阻断」(应限定为解除了客户端侧拒发,端到端 409 依赖仍在)、以及「删除后不再对账」(应限定为结果不再自动回填画布,对账本身继续进行)。
|
||||
- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的恢复 effect 孤儿分支、`useImageCanvasProjectPersistence.ts` 的 legacy 迁移判据。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 验证方式:孤儿用例翻转为「单次读仍无法判定时保留账本、不提示、不刷新素材库」;新增「legacy `failed + operation` 被迁入本机」用例。两条均已实证:回退修复后各报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 完美像素的优先级判据:低成本操作不为「丢资源」加限制
|
||||
|
||||
- 背景:完美像素链路上反复出现同一种压力——为了防止「已经生成的结果丢失关联」,不断有人提议加限制:禁止删除未收口占位、禁止从源图重新发起、让本机账本参与防重。历史上前两条真的加过(2026-08-04 两条决策),造成用户画布上出现删不掉的元素,后来被逐条作废;最近一轮评审又把「删除后可创建第二个 identity」报成必须闭合的 P2 缺口,闭合方向正是第三条。这种压力不会自己停,必须写成判据。
|
||||
- 事实前提:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整、不进外部生成队列,重做一次的代价接近于零。它与计费生成的风险结构根本不同,不能套用同一套「必须防止重复提交」的直觉。
|
||||
- 决策(优先级判据):凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**。只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。评审与复查把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。
|
||||
- 决策(禁止的补救方向):不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。已作废的同类封锁不得以任何理由重新引入。
|
||||
- 连带处置:先前记为「已知缺口、闭合方向是让本机账本参与防重」的那条,改记为**明确接受的行为**——该闭合方向正是本条禁止的内容。既有的 `existingOperation` 闸是本条确立之前的遗留,方向与本条相反,后续应放宽而非加固。
|
||||
- 与 exact retry 的关系:本条不否定 exact retry。它是**用户自愿选择**的幂等路径(原样重放同一 identity,服务端同内容重放返回 `AlreadyApplied`),属于给用户多一个选项,不是限制;被禁止的是把它变成用户唯一能走的路。
|
||||
- 影响范围:仅文档与后续评审口径。不改代码、不改测试。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入同名判据与禁止清单)。
|
||||
|
||||
## 2026-08-05 完美像素跨会话续命层记为「迁移到 durable job 时整层清除」
|
||||
|
||||
- 背景:本分支相对 master 新增约 16758 行,其中测试占 45%(Rust 内联 `#[cfg(test)]` 按行号切开重算:`editor_project.rs` 新增 2882 行里 1221 行是测试,`editor_project_storage.rs` 是 24%)。功能本体极小——像素规整算法在 `pixel_art_snapper.rs` 只改了 100 行。体量几乎全部来自「这条链路没有 durable job」这一个架构选择:免费 + 同步 + 不进生成队列,服务端不留任何「这次请求发出过」的记录,于是「结果是否落库」这个在其它生成路径由 job 行免费回答的问题,必须由客户端自造一整套机制回答。
|
||||
- 三层划分(本条的核心结论):**A 层**服务端正确性(单事务原子落库、preflight、归属校验、稳定 object key、预算边界,约 2800 行)与是否有 job 无关,任何形态都保留;**C 层**POST 后一次 GET 对账(applied / dialog-missing / unknown 三档,约 180 行)保护的是「结果未知却谎报失败」,属于主链路,保留;**B 层**跨会话续命(本机账本、刷新恢复与孤儿对账、75 秒窗口与锚点、marker 与账本的寿命对齐、exact retry、inline 占位到期与归属登记,约 1100 行生产 + 2500 行测试)只因为没有 job 而存在。
|
||||
- 决策:**现在不删 B 层**——它刚写完、刚测过、刚修完六个缺陷,删除本身是有风险的改动,收益兑现在未来的维护成本上。但**迁移到 `enqueue_editor_generation_job` 时必须整层清除,不得与队列并存**:两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。该要求已写入专题文档,作为阶段 4 的验收条件之一。
|
||||
- 支撑该结论的实测(免得后来者重新推导):B 层只服务完美像素——`requiresLiveSession: true` 全仓仅一处置位,`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点都在完美像素的提交、重试与恢复路径上。因此整层删除的边界清晰、不会波及其它生成链路。
|
||||
- 缺陷密度佐证:2026-08-05 那轮对抗性复查的七条发现里,F1–F6 六条**全部**落在 B 层(F7 是文档同步)。B 层的核心不变式「持久化标记的寿命必须与短寿命本地状态对齐」反直觉且容易写错,是这条链路缺陷最密集的地方。
|
||||
- 仓库内既有的廉价答案:手动图集拆分同样免费、同步、无 durable job,客户端只有约 85 行——失败即 `window.alert` 报错,`taskId` 用 `build_prefixed_uuid_id("editor-atlas-split-")` 随机生成,结构上不可能幂等,也没有任何对账。它符合上文的优先级判据,**完美像素的 B 层才是特例**,不得据它给其它链路加同样的机制。
|
||||
- 顺带记录的待办(不在本次范围):图集拆分的 catch 只做 `window.alert('拆分图集失败')`,不区分「确定失败」与「网关合成的未知结果」。服务端可能已经切完并落库 N 个 asset 却报失败,用户照提示重拆就会拿到双份——这属于谎报,落在主链路一侧,与「丢资源不阻断」不是一类问题。最小修法是复用现成的 `isGatewayUnknownOutcomeError` 改文案,十几行,不需要照搬 B 层。
|
||||
- 影响范围:仅文档。不改代码、不改测试。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入三层划分、B 层边界实测与清除条件)。
|
||||
|
||||
## 2026-08-05 删除确认判据改看 marker,覆盖源准备阶段的免费占位
|
||||
|
||||
- 缺陷:`requiresGenerationDeleteConfirmation` 的判据是 `status === 'generating' && !dialog.perfectPixelOperation`,而 `perfectPixelOperation` 要到源图解析 / 直传完成后才写入。未登记的本地图片要走 `ticket → PUT → confirm`,预算上限 90 秒;这段窗口里占位是 `generating`、只有 `requiresLiveSession: true`、没有账本,于是判据返回 true——用户删一个**免费**操作会被告知「已消耗的泥点不会返还」。与同日「完美像素占位恢复为可删除」里「任何状态直接删、不弹确认」的决策直接矛盾。已登记资源走短路解析、窗口接近于零,暴露只在未登记本地图片上成立。
|
||||
- 决策:`perfectPixelOperationId` marker 从占位**创建那一刻**就写上(`operationId === dialogId` 在创建时已知),判据改看 marker:`status === 'generating' && !dialog.perfectPixelOperationId`。marker 的语义也因此更准确——它表示「这个占位属于一次完美像素操作」,而不是「账本已存在」。
|
||||
- 未采纳「删掉弹窗入口」:删除入口全仓只有 `requestRemoveCanvasGenerationDialog` 一条,右键、Delete 快捷键、工具栏全部汇入,完美像素没有自己的删除路径可以摘除。「从本链路删除入口」在实现上等价于「让判据认得出本链路」,绕不开识别问题。真正删掉弹窗只能对所有生成占位一起做,那是另一个产品决定(对计费生成而言该文案是真实信息),不作为修此缺陷的副产品。
|
||||
- 未采纳「新增 `generationCostMudPoints` 字段让判据直接问是否计费」:语义上最正,但今天唯一的生产者只有完美像素,图集拆分根本不创建占位,第二个消费者并不存在,属于为一个调用方过度设计。
|
||||
- 未采纳「判据加 `requiresLiveSession === true`」:该字段全仓确实只有完美像素一处置位,一行即可修,但它的含义是「只能由本会话收口」而非「免费」。换一个代理不解决问题——**用短寿命字段的存在性判断长期属性**正是本缺陷(以及 F5)的成因模式。
|
||||
- 已核过的连带影响:会话内到期清理不受影响,`inlineGenerationPlaceholderExpiryAt` 看的是 `perfectPixelOperation` 而非 marker,源准备阶段被放弃的占位照常到期消失。受影响的只有加载时的快照清理 `dropDeadInlineGenerationPlaceholders`——它的豁免判据接受 marker,因此「源准备中途关标签页」的占位不再被静默清掉,而是在下次加载显示为可删的失败卡。这与「TTL 豁免 = 系统不替用户删,用户主动删除始终允许」一致,判为改善而非退化。id 撞车时 `openCanvasGenerationDialog` 会另生成 id,marker 会暂时指向旧值;它此刻只被当作存在性标记使用(效果仍是不弹确认),写 request 时按真实 dialogId 纠正,不影响任何判等。
|
||||
- 测试缺口的根因:原用例的夹具 `durablePerfectPixelDialog` 带着 `perfectPixelOperation`,编码了与判据相同的错误假设,结构上不可能覆盖 operation 形成之前的窗口。夹具已补上 marker 以还原真实形状,并新增「只有 marker、尚无账本」的用例,已实证:回退判据后报 `expected true to be false`。
|
||||
- 影响范围:`useCanvasGenerationDialogs.ts` 的判据、`useImageCanvasGenerationWorkflow.ts` 的占位创建。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 布局 flush 不再等待封面链
|
||||
|
||||
- 缺陷:`flushProjectPersistence` 显式关掉 `queueProjectLayoutSave` 内建的 fire-and-forget 封面分支(传 `persistCover: false`),自己另起一份并在函数最后 `await coverSave`。于是每一个 `await flushProjectPersistence(...)` 的调用方都被挂在封面链后面。封面渲染要为**每个可绘制图层**取 signed URL、再用 `new Image()` 加载——那个 Image 只有 `onload` / `onerror`,**没有 timeout、没有 AbortSignal**,外层的 `try { } catch { }` 只接得住 reject、接不住「永不 settle」。一张图不 settle,生成 POST 就永远发不出去。
|
||||
- 归因:这是 2026-08-05「完美像素请求账本移出项目布局」把严格通道与普通 flush 合并成一条路径时引入的**回归**。改动前 `if (requireSuccess) { …; await strictCompletion.promise; return; }` 在封面链启动之前就返回,封面与 pre-POST 路径是结构性隔离的。图集拆分(`void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer))`)一直走非严格路径,因此它的暴露是既有的、不是本次引入;但两者同源,一并解开。
|
||||
- 影响面分级:**必然发生**的是延迟——封面签名含未量化的 `viewport.x/y/scale`,而完美像素创建占位时 `openPlacedCanvasGenerationDialog` 会 `setViewport(centerViewportOnPlacement(...))`,所以几乎每次调用都会触发全量重渲染(逐图层取 signed URL + 加载 + 渲染 + 上传 OSS + 登记资源),这些与服务端那个 409 前置毫无关系。**可能发生**的是永久挂死,此时 `snapSelectedLayerToPerfectPixels` 的 `finally` 永不执行,图层锁与 inline 占位归属登记被永久持有;用户即使删掉占位,闸的另一半 `perfectPixelLayerIdsRef.current.has(sourceLayer.id)` 仍为真且**静默 return**,本会话内再点完美像素不会有任何反应。图集拆分没有这层脏状态——它的锁在 `splitSelectedIconSpritesheet` 函数体内才取,flush 挂住时根本没被调用,表现只是「点击无反馈」。
|
||||
- 决策(删除式修复):删掉 flush 里的 `persistCover: false` 覆盖、独立的 `const coverSave = persistProjectCoverSnapshot(...)` 与末尾的 `await coverSave`,让封面回到 `queueProjectLayoutSave` 内建的 fire-and-forget 分支。参数逐项等价(`layoutInput.viewport` 即 `coverDisplayViewport`、同一个 `refs.layersRef.current`、flush 不传 `delayMs` 故走立即分支),**封面照存,只是不再有人等它**。未采纳「给 flush 加一个跳过封面的选项」:那会把同一个结构性问题留在图集拆分身上,并且多一个需要每个调用方正确设置的开关。
|
||||
- 代价:`returnToProjects` 不再等封面就跳转。该保护本就很薄——跳转是 SPA 路由切换而非页面卸载,fire-and-forget 的 promise 在同一 JS 上下文里会跑完;真正会打断它的是浏览器关闭/硬刷新,而 `await` 在 `beforeunload` 里同样救不了。实际损失只是「点返回后立刻关标签页」这个窄窗口里封面可能没传完,而封面是缩略图、下次任意保存会重新生成。
|
||||
- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。
|
||||
- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。
|
||||
- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
|
||||
@@ -14,6 +14,21 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## `timeout_at` 不能替代显式的预算耗尽预检
|
||||
|
||||
- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。
|
||||
- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。
|
||||
- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。
|
||||
- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`acquire_editor_pixel_art_snap_permit`、`acquire_editor_pixel_art_cpu_permit`)。
|
||||
|
||||
## 有界等待队列的计数递减必须写在 Drop 里
|
||||
|
||||
- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。
|
||||
- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。
|
||||
- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。
|
||||
- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`try_enter_bounded_queue`、`EditorPixelArtSnapQueueGuard`)。
|
||||
## Linux 生产脚本门禁不能假设本地也是 GNU userland
|
||||
|
||||
- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。
|
||||
@@ -3236,9 +3251,9 @@
|
||||
|
||||
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
|
||||
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
|
||||
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
|
||||
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。
|
||||
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/active.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
||||
|
||||
## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
- 日期:2026-06-11
|
||||
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
|
||||
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
|
||||
- 涉及代码:`server-rs/crates/spacetime-client/src/active.rs`
|
||||
|
||||
## 故障根因
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
|
||||
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
|
||||
|
||||
这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;`dropped_lease_releases_slot_and_permit` 只覆盖本地 Drop,不构成远端取消测试。
|
||||
|
||||
## 验收
|
||||
|
||||
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
||||
|
||||
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。
|
||||
|
||||
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
|
||||
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、完美像素新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
|
||||
|
||||
恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。
|
||||
- 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。
|
||||
- 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。
|
||||
- 已有图片完美像素化成功落入画布前记录独立 `perfect-pixel` 历史类型,中文提示使用“完美像素”。该类型与 `generate-image`、`expand-image`、`remove-background` 一样属于新增结果保护操作:撤销不得删除派生 PNG,源图继续保留也不改变这一保护语义。像素处理失败、超时或不适用时不写历史。
|
||||
- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId`、`sourceResourceId`、`sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。
|
||||
- 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。
|
||||
- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。
|
||||
@@ -34,6 +35,7 @@
|
||||
- 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。
|
||||
- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。
|
||||
- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。
|
||||
- 完美像素的关闭 composer 占位沿用 generation dialog 的内容存在性保护。占位删除已先持久化时,后端 completion 不得用旧 placeholder 复活占位或派生图层;回包时本地占位已删除则前端不应用完成快照或写 `perfect-pixel` 历史,已经持久化的 project resource / 账号素材仍可由资源或素材入口读取。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。
|
||||
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
|
||||
|
||||
## 验收重点
|
||||
@@ -55,3 +57,5 @@
|
||||
15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。
|
||||
16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。
|
||||
17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。
|
||||
18. 完美像素结果加入画布后只写一条 `perfect-pixel` 历史;第一次撤销命中保护提示,源图和派生 PNG 都不消失。
|
||||
19. 完美像素处理中删除占位后,本次完成回包不得在本地重新应用结果或写 `perfect-pixel` 历史;删除已先持久化时,后端不得复活占位或结果图层;已成功持久化的资源或账号素材允许保留。
|
||||
|
||||
@@ -69,6 +69,21 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
|
||||
|
||||
重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。
|
||||
|
||||
### 3.6 免费同步栅格派生完成
|
||||
|
||||
`POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId` 的 `canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。
|
||||
|
||||
端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。
|
||||
|
||||
该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。
|
||||
|
||||
同步 completion 写画布前必须读取当前 revision 和 dialog,而不能信任请求中提交时的旧 placeholder:
|
||||
|
||||
1. dialog 仍存在时,在同一当前布局上完成一个结果 layer,保留源图,并关联结果 resource;
|
||||
2. dialog 的删除已先持久化时,跳过 layer / dialog 写回,不重建占位、不复活派生图层;已成功持久化的 project resource / 账号素材允许保留;
|
||||
3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口;
|
||||
4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。
|
||||
|
||||
## 4. 存量迁移
|
||||
|
||||
迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等:
|
||||
@@ -110,5 +125,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端
|
||||
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
|
||||
- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。
|
||||
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
|
||||
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。
|
||||
- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。
|
||||
- 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
|
||||
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
|
||||
- `style`:可选生成后处理风格;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。
|
||||
- `style`:可选生成风格,同时影响提交给 provider 的提示词和回图后的像素规整;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。
|
||||
- `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
|
||||
- 模型与尺寸选项:
|
||||
- `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
|
||||
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。
|
||||
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。图标链路使用「每个图标素材均为像素风格」,**不得**使用「画面为像素风格」——图集生成后要按纯色抠像,绿幕底必须保持平整,画面级像素化要求会与同一段提示词里的「纯色背景必须平整无纹理、无渐变」互相拆台;一张图内是多个彼此分离的素材,需要逐个点名,避免模型只把其中一部分做成像素块。注入发生在 `build_editor_icon_spritesheet_prompt` 返回之后,该函数签名和输出契约不变。约束句只随工程化提示词写入**原图 spritesheet** 的 `editor_project_resource` prompt 列;透明结果的 prompt 列是 `"去除纯色背景"`,自动拆分的切片是 `"自动拆分图集"`,两者都不含约束句。与普通图片和角色形象不同,本链路**响应体**的 `prompt` 字段返回的也是含约束句的工程化提示词,而不是用户输入——图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),因此调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。以上 prompt 列写入与响应字段规则都是既有行为,与 `web/master` 一致,本次只是让被回传的模板多了一行。不新增 OSS PUT、项目资源、图集画布项或切片画布项。尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏),等实测。
|
||||
- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。
|
||||
- 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。
|
||||
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。
|
||||
@@ -93,7 +94,7 @@
|
||||
- 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。
|
||||
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
|
||||
- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
- 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
|
||||
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。
|
||||
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。
|
||||
- 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。
|
||||
- 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。
|
||||
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。
|
||||
@@ -118,7 +119,7 @@
|
||||
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
|
||||
- 上传常规参考图后缩略图右下角显示序号。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。
|
||||
- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。
|
||||
- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。
|
||||
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
|
||||
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
|
||||
|
||||
|
||||
@@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) {
|
||||
longNameTable = artifact.subarray(contentStart, contentEnd);
|
||||
} else if (/^\/\d+$/u.test(rawName) && longNameTable) {
|
||||
const nameOffset = Number.parseInt(rawName.slice(1), 10);
|
||||
const nameEnd = longNameTable.indexOf(0x0a, nameOffset);
|
||||
const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length;
|
||||
// GNU archives use newline-terminated names; MSVC COFF archives use NUL.
|
||||
const candidateNameEnds = [
|
||||
longNameTable.indexOf(0x00, nameOffset),
|
||||
longNameTable.indexOf(0x0a, nameOffset),
|
||||
].filter((nameEnd) => nameEnd >= 0);
|
||||
const resolvedEnd =
|
||||
candidateNameEnds.length > 0
|
||||
? Math.min(...candidateNameEnds)
|
||||
: longNameTable.length;
|
||||
memberName = longNameTable
|
||||
.subarray(nameOffset, resolvedEnd)
|
||||
.toString('utf8')
|
||||
|
||||
@@ -1963,6 +1963,147 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "editor-resource-source",
|
||||
"projectId": "proj-source",
|
||||
"canvasCompletion": {
|
||||
"dialogId": "dialog-pixel-art",
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_rejects_inline_source_before_processing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body");
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"projectId": "proj-source",
|
||||
"sourceResourceId": "editor-resource-source",
|
||||
"assetKind": "character",
|
||||
"generationInputs": { "fields": [], "references": [] },
|
||||
"canvasCompletion": {
|
||||
"dialogId": "dialog-pixel-art",
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline pixel-art sources: {body_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog");
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "editor-resource-source",
|
||||
"projectId": "proj-does-not-exist",
|
||||
"canvasCompletion": {
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("canvasCompletion.dialogId"),
|
||||
"handler should reject missing dialog ids before project lookup: {body_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,24 @@ impl AppError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。
|
||||
/// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留
|
||||
/// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。
|
||||
pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self {
|
||||
match self.details.take() {
|
||||
Some(Value::Object(mut object)) => {
|
||||
object.insert(key.to_string(), value);
|
||||
self.details = Some(Value::Object(object));
|
||||
}
|
||||
// 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象,
|
||||
// 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。
|
||||
_ => {
|
||||
self.details = Some(serde_json::json!({ key: value }));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
|
||||
self.headers.insert(name, value);
|
||||
self
|
||||
|
||||
@@ -22,9 +22,9 @@ use crate::{
|
||||
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
|
||||
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
|
||||
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
|
||||
split_editor_icon_spritesheet, submit_editor_asset_showcase,
|
||||
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
|
||||
update_editor_project_resource_showcase,
|
||||
snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
|
||||
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
|
||||
update_editor_asset_folder, update_editor_project_resource_showcase,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -205,6 +205,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/pixel-art-snaps",
|
||||
post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/icon-spritesheets/generations",
|
||||
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -272,6 +272,7 @@ pub struct AppStateInner {
|
||||
bgfilter_image_validation_limiter: Arc<Semaphore>,
|
||||
character_animation_oss_http_client: reqwest::Client,
|
||||
character_animation_oss_io_limiter: Arc<Semaphore>,
|
||||
editor_oss_http_client: reqwest::Client,
|
||||
#[cfg(any())]
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
|
||||
@@ -530,6 +531,7 @@ impl AppState {
|
||||
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
|
||||
let character_animation_oss_io_limiter =
|
||||
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
|
||||
let editor_oss_http_client = build_editor_oss_http_client()?;
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
let (profile_recharge_order_updates, _) = broadcast::channel(128);
|
||||
|
||||
@@ -577,6 +579,7 @@ impl AppState {
|
||||
bgfilter_image_validation_limiter,
|
||||
character_animation_oss_http_client,
|
||||
character_animation_oss_io_limiter,
|
||||
editor_oss_http_client,
|
||||
#[cfg(any())]
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
#[cfg(any())]
|
||||
@@ -1317,6 +1320,10 @@ impl AppState {
|
||||
self.character_animation_oss_io_limiter.clone()
|
||||
}
|
||||
|
||||
pub fn editor_oss_http_client(&self) -> &reqwest::Client {
|
||||
&self.editor_oss_http_client
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
|
||||
self.creative_agent_executor.clone()
|
||||
@@ -2089,6 +2096,31 @@ fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppSta
|
||||
})
|
||||
}
|
||||
|
||||
// 中文注释:编辑器图片的 OSS 读写此前每次调用都 `reqwest::Client::new()`,既没有任何
|
||||
// 超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着 HTTP 准入许可和
|
||||
// 已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,给整段请求(含 body
|
||||
// 流式读写)一个绝对上界,作为所有调用方的兜底。读写共用一个客户端,两个方向都受同一份
|
||||
// EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 约束,合用也能让 GET / PUT / HEAD 复用连接池。
|
||||
//
|
||||
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
|
||||
// total 取 120s:按较慢的写方向定尺寸——32 MiB 上传在 60s 内要求持续约 4.4 Mbps,
|
||||
// 留一倍余量避免误伤正常流量。读方向不因此变松:完美像素的 GET 另受 30s 处理预算约束,
|
||||
// 客户端超时只是兜底。
|
||||
fn build_editor_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(8)
|
||||
.tcp_keepalive(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppStateInitError::DependencyUnavailable(format!(
|
||||
"初始化编辑器 OSS HTTP 客户端失败:{error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wechat_client(config: &AppConfig) -> WechatClient {
|
||||
WechatClient::new(WechatConfig {
|
||||
app_id: config.wechat_mini_program_app_id.clone(),
|
||||
@@ -2236,6 +2268,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_reuses_editor_oss_client() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
assert!(std::ptr::eq(
|
||||
state.editor_oss_http_client(),
|
||||
state.editor_oss_http_client(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgfilter_image_validation_limiter_is_bounded_per_process_role() {
|
||||
let parent = AppState::new(AppConfig::default()).expect("parent state should build");
|
||||
|
||||
@@ -5,7 +5,8 @@ pub mod vector_engine;
|
||||
|
||||
pub use pixel_art_snapper::{
|
||||
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
|
||||
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline,
|
||||
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline,
|
||||
snap_pixel_art_with_deadline,
|
||||
};
|
||||
pub use vector_engine::{
|
||||
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
|
||||
|
||||
@@ -48,6 +48,7 @@ const DEADLINE_CHECK_INTERVAL: usize = 4_096;
|
||||
#[derive(Debug)]
|
||||
pub enum PixelArtSnapError {
|
||||
InvalidInput(String),
|
||||
GridNotDetected,
|
||||
Decode {
|
||||
input: &'static str,
|
||||
message: String,
|
||||
@@ -63,6 +64,7 @@ impl fmt::Display for PixelArtSnapError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"),
|
||||
Self::GridNotDetected => write!(formatter, "像素规整未识别到可规整的像素网格"),
|
||||
Self::Decode { input, message } => {
|
||||
write!(formatter, "像素规整无法解码 {input}:{message}")
|
||||
}
|
||||
@@ -154,23 +156,38 @@ impl SnapConfig {
|
||||
/// image is always a PNG at the original `rgba_source` dimensions. Its alpha
|
||||
/// channel contains only `0` or `255`, and fully transparent pixels are
|
||||
/// canonical `[0, 0, 0, 0]`.
|
||||
pub fn snap_pixel_art(
|
||||
grid_source: &DownloadedImage,
|
||||
rgba_source: &DownloadedImage,
|
||||
) -> Result<DownloadedImage, PixelArtSnapError> {
|
||||
snap_pixel_art_with_deadline(grid_source, rgba_source, None)
|
||||
}
|
||||
|
||||
/// Deadline-aware variant of [`snap_pixel_art`].
|
||||
///
|
||||
/// The deadline is checked before and after non-cooperative codec/resize
|
||||
/// operations, and periodically inside the K-means, profile, and cell-sampling
|
||||
/// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without
|
||||
/// producing a partial image.
|
||||
/// producing a partial image. Pass `None` to opt out of deadline checks.
|
||||
pub fn snap_pixel_art_with_deadline(
|
||||
grid_source: &DownloadedImage,
|
||||
rgba_source: &DownloadedImage,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<DownloadedImage, PixelArtSnapError> {
|
||||
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false)
|
||||
}
|
||||
|
||||
/// Snap an image unless legacy analysis detects no grid step on either axis.
|
||||
///
|
||||
/// Unlike [`snap_pixel_art_with_deadline`], this entry does not synthesize a
|
||||
/// uniform min-dimension/64 grid when neither axis contains a detectable step.
|
||||
/// Every other detection, walking, sampling, and encoding behavior — including
|
||||
/// the deadline semantics — is identical.
|
||||
pub fn snap_pixel_art_strict_with_deadline(
|
||||
grid_source: &DownloadedImage,
|
||||
rgba_source: &DownloadedImage,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<DownloadedImage, PixelArtSnapError> {
|
||||
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true)
|
||||
}
|
||||
|
||||
fn snap_pixel_art_with_grid_policy(
|
||||
grid_source: &DownloadedImage,
|
||||
rgba_source: &DownloadedImage,
|
||||
deadline: Option<Instant>,
|
||||
reject_uniform_grid_fallback: bool,
|
||||
) -> Result<DownloadedImage, PixelArtSnapError> {
|
||||
let deadline = DeadlineGuard::new(deadline);
|
||||
deadline.check("输入解码")?;
|
||||
@@ -197,6 +214,9 @@ pub fn snap_pixel_art_with_deadline(
|
||||
let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?;
|
||||
let estimated_x = estimate_step_size(&profile_x, config);
|
||||
let estimated_y = estimate_step_size(&profile_y, config);
|
||||
if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() {
|
||||
return Err(PixelArtSnapError::GridNotDetected);
|
||||
}
|
||||
let (step_x, step_y) = resolve_step_sizes(
|
||||
estimated_x,
|
||||
estimated_y,
|
||||
@@ -1076,7 +1096,8 @@ mod tests {
|
||||
let grid = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255])));
|
||||
let rgba = downloaded_png(RgbaImage::from_pixel(8, 9, Rgba([10, 20, 30, 255])));
|
||||
|
||||
let error = snap_pixel_art(&grid, &rgba).expect_err("dimensions should mismatch");
|
||||
let error = snap_pixel_art_with_deadline(&grid, &rgba, None)
|
||||
.expect_err("dimensions should mismatch");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
@@ -1102,6 +1123,58 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() {
|
||||
let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])));
|
||||
|
||||
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
|
||||
.expect("legacy generation style should retain its uniform fallback");
|
||||
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
|
||||
.expect_err("explicit strict action should reject an undetected grid");
|
||||
|
||||
assert_eq!(decode_output(&legacy).dimensions(), (128, 128));
|
||||
assert!(matches!(strict, PixelArtSnapError::GridNotDetected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() {
|
||||
for (vertical_lines, horizontal_lines, expected_axes) in [
|
||||
(true, false, (true, false)),
|
||||
(false, true, (false, true)),
|
||||
(true, true, (true, true)),
|
||||
] {
|
||||
let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]));
|
||||
for y in 0..128 {
|
||||
for x in 0..128 {
|
||||
if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) {
|
||||
image.put_pixel(x, y, Rgba([240, 220, 80, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = SnapConfig::PRODUCTION;
|
||||
let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None))
|
||||
.expect("test image should quantize");
|
||||
let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None))
|
||||
.expect("test profiles should compute");
|
||||
assert_eq!(
|
||||
(
|
||||
estimate_step_size(&profile_x, config).is_some(),
|
||||
estimate_step_size(&profile_y, config).is_some(),
|
||||
),
|
||||
expected_axes,
|
||||
);
|
||||
|
||||
let source = downloaded_png(image);
|
||||
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
|
||||
.expect("legacy processing should succeed with a detected step");
|
||||
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
|
||||
.expect("strict processing should reuse the detected legacy step");
|
||||
assert_eq!(strict.bytes, legacy.bytes);
|
||||
assert_eq!(strict.mime_type, legacy.mime_type);
|
||||
assert_eq!(strict.extension, legacy.extension);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_keeps_physical_size_and_uses_nearest_blocks() {
|
||||
let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255]));
|
||||
@@ -1121,9 +1194,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let output = snap_pixel_art(
|
||||
let output = snap_pixel_art_with_deadline(
|
||||
&downloaded_png(grid_image),
|
||||
&downloaded_png(rgba_image.clone()),
|
||||
None,
|
||||
)
|
||||
.expect("pixel snapping should succeed");
|
||||
let decoded = decode_output(&output);
|
||||
@@ -1150,8 +1224,10 @@ mod tests {
|
||||
}
|
||||
let rgba = downloaded_png(rgba);
|
||||
|
||||
let first = snap_pixel_art(&grid, &rgba).expect("first snap should succeed");
|
||||
let second = snap_pixel_art(&grid, &rgba).expect("second snap should succeed");
|
||||
let first =
|
||||
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("first snap should succeed");
|
||||
let second =
|
||||
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("second snap should succeed");
|
||||
assert_eq!(first.bytes, second.bytes);
|
||||
|
||||
for pixel in decode_output(&first).pixels() {
|
||||
|
||||
@@ -1,5 +1,111 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtCanvasPlaceholderRecordInput {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub original_width: f64,
|
||||
pub original_height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtCanvasCompletionRecordInput {
|
||||
pub dialog_id: String,
|
||||
pub title: String,
|
||||
pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtResultPreflightRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: String,
|
||||
pub asset_folder_id: String,
|
||||
pub project_resource: EditorProjectResourceCreateRecordInput,
|
||||
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtResultPersistRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_fingerprint: String,
|
||||
pub asset_object: module_assets::AssetObjectUpsertInput,
|
||||
pub project_resource: EditorProjectResourceCreateRecordInput,
|
||||
pub asset: EditorAssetCreateRecordInput,
|
||||
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtResultPersistRecord {
|
||||
pub asset_object: module_assets::AssetObjectUpsertSnapshot,
|
||||
pub project_resource: EditorProjectResourceRecord,
|
||||
pub asset: EditorAssetRecord,
|
||||
pub project: Option<EditorProjectRecord>,
|
||||
}
|
||||
|
||||
impl From<EditorPixelArtCanvasPlaceholderRecordInput>
|
||||
for crate::module_bindings::EditorPixelArtCanvasPlaceholderInput
|
||||
{
|
||||
fn from(input: EditorPixelArtCanvasPlaceholderRecordInput) -> Self {
|
||||
Self {
|
||||
x: input.x,
|
||||
y: input.y,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
original_width: input.original_width,
|
||||
original_height: input.original_height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorPixelArtCanvasCompletionRecordInput>
|
||||
for crate::module_bindings::EditorPixelArtCanvasCompletionInput
|
||||
{
|
||||
fn from(input: EditorPixelArtCanvasCompletionRecordInput) -> Self {
|
||||
Self {
|
||||
dialog_id: input.dialog_id,
|
||||
title: input.title,
|
||||
placeholder: input.placeholder.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorPixelArtResultPreflightRecordInput>
|
||||
for crate::module_bindings::EditorPixelArtResultPreflightInput
|
||||
{
|
||||
fn from(input: EditorPixelArtResultPreflightRecordInput) -> Self {
|
||||
Self {
|
||||
owner_user_id: input.owner_user_id,
|
||||
project_id: input.project_id,
|
||||
asset_folder_id: input.asset_folder_id,
|
||||
project_resource: input.project_resource.into(),
|
||||
canvas_completion: input.canvas_completion.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorPixelArtResultPersistRecordInput>
|
||||
for crate::module_bindings::EditorPixelArtResultPersistInput
|
||||
{
|
||||
fn from(input: EditorPixelArtResultPersistRecordInput) -> Self {
|
||||
Self {
|
||||
owner_user_id: input.owner_user_id,
|
||||
project_id: input.project_id,
|
||||
operation_id: input.operation_id,
|
||||
operation_fingerprint: input.operation_fingerprint,
|
||||
asset_object: input.asset_object.into(),
|
||||
project_resource: input.project_resource.into(),
|
||||
asset: input.asset.into(),
|
||||
canvas_completion: input.canvas_completion.into(),
|
||||
completed_at_micros: input.completed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorSpritesheetSlicePersistItemRecordInput {
|
||||
pub asset_object: module_assets::AssetObjectUpsertInput,
|
||||
@@ -57,6 +163,56 @@ impl From<EditorSpritesheetSliceBatchPersistRecordInput>
|
||||
}
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn preflight_editor_pixel_art_result(
|
||||
&self,
|
||||
input: EditorPixelArtResultPreflightRecordInput,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"preflight_editor_pixel_art_result_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.preflight_editor_pixel_art_result_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_pixel_art_result_preflight_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn persist_editor_pixel_art_result(
|
||||
&self,
|
||||
input: EditorPixelArtResultPersistRecordInput,
|
||||
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"persist_editor_pixel_art_result_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.persist_editor_pixel_art_result_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_pixel_art_result_persist_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn persist_editor_spritesheet_slice_batch(
|
||||
&self,
|
||||
input: EditorSpritesheetSliceBatchPersistRecordInput,
|
||||
@@ -981,6 +1137,59 @@ impl SpacetimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_pixel_art_result_preflight_result(
|
||||
result: crate::module_bindings::EditorPixelArtResultPreflightResult,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
if result.ok {
|
||||
return Ok(());
|
||||
}
|
||||
Err(SpacetimeClientError::procedure_failed(result.error_message))
|
||||
}
|
||||
|
||||
fn map_editor_pixel_art_result_persist_result(
|
||||
result: crate::module_bindings::EditorPixelArtResultPersistResult,
|
||||
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
// 中文注释:三种落库状态(Applied / DialogMissing / AlreadyApplied)目前没有任何
|
||||
// 上层消费方,不再向上透传;但状态缺失说明 procedure 返回体不完整,仍要拦在这里。
|
||||
if result.status.is_none() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"完美像素持久化结果缺少状态",
|
||||
));
|
||||
}
|
||||
let asset_object = result.asset_object.ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed("完美像素持久化结果缺少 asset object")
|
||||
})?;
|
||||
let project_resource = result
|
||||
.project_resource
|
||||
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少项目资源"))?;
|
||||
let asset = result
|
||||
.asset
|
||||
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少账号素材"))?;
|
||||
let project = result
|
||||
.project
|
||||
.map(|project| {
|
||||
map_editor_project_optional_procedure_result(
|
||||
crate::module_bindings::EditorProjectProcedureResult {
|
||||
ok: true,
|
||||
project: Some(project),
|
||||
error_message: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
Ok(EditorPixelArtResultPersistRecord {
|
||||
asset_object: map_editor_spritesheet_asset_object_snapshot(asset_object),
|
||||
project_resource: map_editor_spritesheet_project_resource_snapshot(project_resource)?,
|
||||
asset: map_editor_spritesheet_asset_snapshot(asset)?,
|
||||
project,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_editor_spritesheet_slice_batch_persist_result(
|
||||
result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult,
|
||||
) -> Result<EditorSpritesheetSliceBatchPersistRecord, SpacetimeClientError> {
|
||||
@@ -1113,3 +1322,181 @@ fn parse_editor_spritesheet_generation_inputs(
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pixel_art_persist_mapper_tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_pixel_art_persist_result(
|
||||
ok: bool,
|
||||
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
|
||||
crate::module_bindings::EditorPixelArtResultPersistResult {
|
||||
ok,
|
||||
status: None,
|
||||
asset_object: None,
|
||||
project_resource: None,
|
||||
asset: None,
|
||||
project: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_art_asset_object_snapshot() -> crate::module_bindings::AssetObjectUpsertSnapshot {
|
||||
crate::module_bindings::AssetObjectUpsertSnapshot {
|
||||
asset_object_id: "assetobj_pixel".to_string(),
|
||||
bucket: "private".to_string(),
|
||||
object_key: "editor/pixel.png".to_string(),
|
||||
access_policy: crate::module_bindings::AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 16,
|
||||
content_hash: Some("output-sha256".to_string()),
|
||||
version: 1,
|
||||
source_job_id: Some("pixel-art-snap-dialog-1".to_string()),
|
||||
owner_user_id: Some("user-1".to_string()),
|
||||
profile_id: None,
|
||||
entity_id: Some("project-1".to_string()),
|
||||
asset_kind: "image".to_string(),
|
||||
created_at_micros: 10,
|
||||
updated_at_micros: 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_art_project_resource_snapshot() -> crate::module_bindings::EditorProjectResourceSnapshot
|
||||
{
|
||||
crate::module_bindings::EditorProjectResourceSnapshot {
|
||||
resource_id: "editor-resource-pixel".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
asset_object_id: Some("assetobj_pixel".to_string()),
|
||||
image_src: "/api/assets/assetobj_pixel/content".to_string(),
|
||||
object_key: Some("editor/pixel.png".to_string()),
|
||||
width: 32,
|
||||
height: 32,
|
||||
source_type: "perfect-pixel".to_string(),
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
task_id: Some("pixel-art-snap-dialog-1".to_string()),
|
||||
source_resource_id: Some("source-resource".to_string()),
|
||||
asset_kind: Some("image".to_string()),
|
||||
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
|
||||
public_showcase_enabled: false,
|
||||
created_at_micros: 10,
|
||||
updated_at_micros: 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_art_asset_snapshot() -> crate::module_bindings::EditorAssetSnapshot {
|
||||
crate::module_bindings::EditorAssetSnapshot {
|
||||
asset_id: "editor-asset-pixel".to_string(),
|
||||
folder_id: "folder-1".to_string(),
|
||||
label: "Pixel result".to_string(),
|
||||
asset_object_id: Some("assetobj_pixel".to_string()),
|
||||
image_src: "/api/assets/assetobj_pixel/content".to_string(),
|
||||
object_key: Some("editor/pixel.png".to_string()),
|
||||
width: 32,
|
||||
height: 32,
|
||||
source_type: "perfect-pixel".to_string(),
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
task_id: Some("pixel-art-snap-dialog-1".to_string()),
|
||||
asset_kind: Some("image".to_string()),
|
||||
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
|
||||
source_resource_id: Some("source-resource".to_string()),
|
||||
public_showcase_enabled: Some(false),
|
||||
created_at_micros: 10,
|
||||
updated_at_micros: 11,
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: 0,
|
||||
showcase_id: None,
|
||||
showcase_review_status: None,
|
||||
showcase_display_enabled: None,
|
||||
showcase_like_count: None,
|
||||
group_task_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn successful_pixel_art_persist_result(
|
||||
status: crate::module_bindings::EditorPixelArtResultPersistStatus,
|
||||
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
|
||||
crate::module_bindings::EditorPixelArtResultPersistResult {
|
||||
ok: true,
|
||||
status: Some(status),
|
||||
asset_object: Some(pixel_art_asset_object_snapshot()),
|
||||
project_resource: Some(pixel_art_project_resource_snapshot()),
|
||||
asset: Some(pixel_art_asset_snapshot()),
|
||||
project: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_pixel_art_persist_mapper_rejects_procedure_failure_before_snapshots() {
|
||||
let mut result = empty_pixel_art_persist_result(false);
|
||||
result.error_message = Some("事务提交失败".to_string());
|
||||
|
||||
let error = map_editor_pixel_art_result_persist_result(result)
|
||||
.expect_err("ok=false 必须保留 procedure 失败");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
SpacetimeClientError::Procedure(message) if message == "事务提交失败"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_pixel_art_persist_mapper_rejects_missing_required_snapshot() {
|
||||
let mut result = empty_pixel_art_persist_result(true);
|
||||
result.status = Some(crate::module_bindings::EditorPixelArtResultPersistStatus::Applied);
|
||||
|
||||
let error = map_editor_pixel_art_result_persist_result(result)
|
||||
.expect_err("成功结果缺少 asset object 时必须拒绝");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
SpacetimeClientError::Runtime(message)
|
||||
if message == "完美像素持久化结果缺少 asset object"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_pixel_art_persist_mapper_rejects_missing_status() {
|
||||
let mut result = successful_pixel_art_persist_result(
|
||||
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied,
|
||||
);
|
||||
result.status = None;
|
||||
|
||||
let error = map_editor_pixel_art_result_persist_result(result)
|
||||
.expect_err("成功结果缺少状态时必须拒绝");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
SpacetimeClientError::Runtime(message)
|
||||
if message == "完美像素持久化结果缺少状态"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_pixel_art_persist_mapper_accepts_every_status_and_missing_project() {
|
||||
let cases = [
|
||||
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied,
|
||||
crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing,
|
||||
crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied,
|
||||
];
|
||||
|
||||
for binding_status in cases {
|
||||
let mapped = map_editor_pixel_art_result_persist_result(
|
||||
successful_pixel_art_persist_result(binding_status),
|
||||
)
|
||||
.expect("project=None 仍应映射成功");
|
||||
|
||||
assert_eq!(mapped.asset_object.asset_object_id, "assetobj_pixel");
|
||||
assert_eq!(mapped.project_resource.resource_id, "editor-resource-pixel");
|
||||
assert_eq!(mapped.asset.asset_id, "editor-asset-pixel");
|
||||
assert!(mapped.project.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,13 @@ pub mod editor_generation_pricing_tier_type;
|
||||
pub mod editor_generation_runtime_identity_rotate_input_type;
|
||||
pub mod editor_generation_runtime_identity_rotation_table;
|
||||
pub mod editor_generation_runtime_identity_rotation_type;
|
||||
pub mod editor_pixel_art_canvas_completion_input_type;
|
||||
pub mod editor_pixel_art_canvas_placeholder_input_type;
|
||||
pub mod editor_pixel_art_result_persist_input_type;
|
||||
pub mod editor_pixel_art_result_persist_result_type;
|
||||
pub mod editor_pixel_art_result_persist_status_type;
|
||||
pub mod editor_pixel_art_result_preflight_input_type;
|
||||
pub mod editor_pixel_art_result_preflight_result_type;
|
||||
pub mod editor_project_create_input_type;
|
||||
pub mod editor_project_delete_input_type;
|
||||
pub mod editor_project_delete_procedure_result_type;
|
||||
@@ -475,10 +482,12 @@ pub mod npc_relation_state_type;
|
||||
pub mod npc_stance_profile_type;
|
||||
pub mod npc_state_table;
|
||||
pub mod npc_state_type;
|
||||
pub mod persist_editor_pixel_art_result_and_return_procedure;
|
||||
pub mod persist_editor_spritesheet_slice_batch_and_return_procedure;
|
||||
pub mod player_progression_grant_source_type;
|
||||
pub mod player_progression_table;
|
||||
pub mod player_progression_type;
|
||||
pub mod preflight_editor_pixel_art_result_and_return_procedure;
|
||||
pub mod prepare_profile_recharge_refund_hold_and_return_procedure;
|
||||
pub mod preview_profile_recharge_refund_hold_and_return_procedure;
|
||||
pub mod profile_code_operation_table;
|
||||
@@ -1120,6 +1129,13 @@ pub use editor_generation_pricing_tier_type::EditorGenerationPricingTier;
|
||||
pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput;
|
||||
pub use editor_generation_runtime_identity_rotation_table::*;
|
||||
pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation;
|
||||
pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
|
||||
pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput;
|
||||
pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput;
|
||||
pub use editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult;
|
||||
pub use editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus;
|
||||
pub use editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput;
|
||||
pub use editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult;
|
||||
pub use editor_project_create_input_type::EditorProjectCreateInput;
|
||||
pub use editor_project_delete_input_type::EditorProjectDeleteInput;
|
||||
pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult;
|
||||
@@ -1298,10 +1314,12 @@ pub use npc_relation_state_type::NpcRelationState;
|
||||
pub use npc_stance_profile_type::NpcStanceProfile;
|
||||
pub use npc_state_table::*;
|
||||
pub use npc_state_type::NpcState;
|
||||
pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return;
|
||||
pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return;
|
||||
pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
|
||||
pub use player_progression_table::*;
|
||||
pub use player_progression_type::PlayerProgression;
|
||||
pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return;
|
||||
pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return;
|
||||
pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return;
|
||||
pub use profile_code_operation_table::*;
|
||||
@@ -5061,19 +5079,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle {
|
||||
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
|
||||
pub trait RemoteDbContext:
|
||||
__sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
{
|
||||
}
|
||||
impl<
|
||||
Ctx: __sdk::DbContext<
|
||||
Ctx: __sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>,
|
||||
> RemoteDbContext for Ctx
|
||||
> RemoteDbContext for Ctx
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorPixelArtCanvasCompletionInput {
|
||||
pub dialog_id: String,
|
||||
pub title: String,
|
||||
pub placeholder: EditorPixelArtCanvasPlaceholderInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorPixelArtCanvasCompletionInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorPixelArtCanvasPlaceholderInput {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub original_width: f64,
|
||||
pub original_height: f64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorPixelArtCanvasPlaceholderInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::asset_object_upsert_input_type::AssetObjectUpsertInput;
|
||||
use super::editor_asset_create_input_type::EditorAssetCreateInput;
|
||||
use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
|
||||
use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorPixelArtResultPersistInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_fingerprint: String,
|
||||
pub asset_object: AssetObjectUpsertInput,
|
||||
pub project_resource: EditorProjectResourceCreateInput,
|
||||
pub asset: EditorAssetCreateInput,
|
||||
pub canvas_completion: EditorPixelArtCanvasCompletionInput,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorPixelArtResultPersistInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
|
||||
use super::editor_asset_snapshot_type::EditorAssetSnapshot;
|
||||
use super::editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus;
|
||||
use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
|
||||
use super::editor_project_snapshot_type::EditorProjectSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorPixelArtResultPersistResult {
|
||||
pub ok: bool,
|
||||
pub status: Option<EditorPixelArtResultPersistStatus>,
|
||||
pub asset_object: Option<AssetObjectUpsertSnapshot>,
|
||||
pub project_resource: Option<EditorProjectResourceSnapshot>,
|
||||
pub asset: Option<EditorAssetSnapshot>,
|
||||
pub project: Option<EditorProjectSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorPixelArtResultPersistResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user