From 7f13af6135646d12c95d52296b9369872194fd87 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 02:29:53 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E7=BB=93=E6=9E=9C=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E6=9C=AA=E7=9F=A5=E7=8A=B6=E6=80=81=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为完美像素操作引入稳定标识、请求指纹和原子持久化 procedure,支持精确重放并拒绝冲突。 在首次对象存储写入后完整标记 resultPersistenceStarted,统一未知结果的 GET 对账语义。 前端持久化 perfectPixelOperation,提交前严格刷写布局,并支持 pending-confirmation、刷新恢复与原样重试。 隔离跨用户与跨项目回包,修正占位清理、动画状态和异常对账时间窗。 同步生成 SpacetimeDB 绑定,补充前后端回归测试,并更新技术文档与共享决策记录。 --- .../shared-memory/decision-log.md | 12 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 852 ++++++++--- .../spacetime-client/src/editor_project.rs | 339 +++++ .../spacetime-client/src/module_bindings.rs | 12 + ..._pixel_art_canvas_completion_input_type.rs | 19 + ...pixel_art_canvas_placeholder_input_type.rs | 20 + ...tor_pixel_art_result_persist_input_type.rs | 28 + ...or_pixel_art_result_persist_result_type.rs | 27 + ...or_pixel_art_result_persist_status_type.rs | 20 + ...r_pixel_art_result_and_return_procedure.rs | 59 + .../src/editor_project_storage.rs | 1073 ++++++++++++++ .../ImageCanvasEditorModel.test.ts | 394 ++++- .../image-editor/ImageCanvasEditorModel.ts | 502 ++++++- .../image-editor/ImageCanvasEditorTypes.ts | 15 +- .../image-editor/ImageCanvasEditorView.tsx | 9 +- ...ImageCanvasGenerationComposerView.test.tsx | 103 ++ .../ImageCanvasGenerationComposerView.tsx | 76 +- .../ImageCanvasQuickEditPanelView.tsx | 5 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 26 + .../ImageCanvasSelectedLayerToolbarView.tsx | 14 +- .../image-editor/ImageCanvasStageView.tsx | 6 + .../ImageCanvasWorldView.test.tsx | 134 +- .../image-editor/ImageCanvasWorldView.tsx | 52 +- .../useCanvasGenerationDialogs.test.tsx | 25 + .../useCanvasGenerationDialogs.ts | 42 +- .../useImageCanvasGenerationSurface.tsx | 13 +- .../useImageCanvasGenerationWorkflow.test.tsx | 1290 +++++++++++------ .../useImageCanvasGenerationWorkflow.ts | 1017 +++++++++++-- .../useImageCanvasProjectPersistence.test.tsx | 566 ++++++++ .../useImageCanvasProjectPersistence.ts | 317 +++- ...InlineGenerationPlaceholderExpiry.test.tsx | 105 +- src/index.css | 12 + .../image-editor/editorProjectClient.test.ts | 34 + .../image-editor/editorProjectClient.ts | 17 +- 36 files changed, 6331 insertions(+), 916 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1692ab476..115767b26 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6183,3 +6183,15 @@ - 后果分级要说清:缺陷一是永久性的 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 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。v1 快照跨度不得超过固定 240 秒,读取时面对未来时钟偏差也至多从当前再观察一个窗口。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation 快照的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 2f8d2bad6..19959e0e3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -51,7 +51,7 @@ - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 - 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局,最后把承载稳定候选引用的 `sourceImageSrc`、必填 `projectId / canvasCompletion`(其中 `canvasCompletion.dialogId` 必须非空)以及可选 `sourceResourceId / assetFolderId / assetLabel` 一次提交。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片必须先上传,正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 - 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 -- strict 的零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败。进入 OSS / SpacetimeDB 持久化后沿用现有非事务顺序,后段 resource / asset / completion 失败可能保留此前已确认对象或记录;客户端不做自动重放或删除补偿,而是按 `taskId / objectKey / resourceId` 重新读取项目和素材快照。 +- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。结果未知时先重新读取项目 / 素材快照,由用户显式决定是否再次执行。处理成功后若对应 generation dialog 的删除已先持久化,后端沿用现有 completion 语义跳过画布插入,不得复活占位或结果图层;回包时本地占位已删除则前端不应用完成快照,已经成功持久化的 project resource / 账号素材可以保留。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。 ### 角色动作帧抠图像素边界 @@ -75,7 +75,7 @@ ## 数据与持久化 -- 完美像素派生结果沿用 `editor_project_resource / editor_asset / canvasCompletion` 三段式持久化,不新增表、资源类型或队列类型。源图已有正式 project resource 时,成功结果的 `sourceResourceId` 指向该资源;最终 PNG 只 PUT 一次,同一请求至多创建一个 project resource 和一个账号素材。像素处理本身失败时三类业务写入都不发生;像素处理成功但 completion 读取的权威占位已删除时,只保留已创建的资源 / 素材并跳过画布 layer,不能用提交时旧 placeholder 把图层重新插回。 +- 完美像素派生结果仍复用既有 `asset_object / editor_project_resource / editor_asset / canvasCompletion` 数据模型,不新增表、资源类型或队列类型,但四类数据库事实改由单个 procedure 原子持久化。源图已有正式 project resource 时,成功结果的 `sourceResourceId` 指向该资源;最终 PNG 只 PUT 一次,同一 operation 至多对应一个 project resource 和一个账号素材。像素处理本身失败时三类业务写入都不发生;像素处理成功但事务内读取的权威占位已删除时,object / resource / asset 同事务提交并返回 `DialogMissing`,canvas / revision 保持不变,不能用提交时旧 placeholder 把图层重新插回。 - 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 @@ -123,7 +123,7 @@ - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 -- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。权威 `canvasCompletion.dialogId` 仍存在时在源图右侧完成占位;其删除已先持久化时只跳过画布回填,不删除已经成功写入的资源或账号素材。 +- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -170,6 +170,10 @@ - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放,因此 catch 必须区分已知与未知结果:服务端在第一次 OSS PUT 之后的失败路径上置 `details.resultPersistenceStarted = true`;未带该标记的响应(纯校验 `400`、排队 `503`、预算 `504` 等)表示尚未落库,直接标失败;transport 异常、abort、客户端超时以及带该标记的响应属于结果可能已落库,必须先 GET 权威项目快照对账——占位已被 completion 消费掉则按快照收口并写入正常的完美像素历史,占位仍存活则只同步快照、不写历史,并明确告知结果未知且素材库可能已有派生图,由用户先核对再决定是否重试。刷新后停在 `generating` 的占位目前仍无自动收口,需要用户手动处理。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 第一批原子持久化只收口服务端事实,不宣称前端恢复已完成:当前前端仍可能把项目 GET 与素材刷新绑在同一个等待窗口,也尚未把 operation 请求快照、`pending-confirmation` 状态和刷新后的 GET-only 恢复持久化。后续前端批次必须让素材刷新退出 verdict,按稳定 task / resource 有界轮询项目,unknown 到期保持待确认而非标记普通失败,人工重试复用原 dialog / operation / 请求快照;在这些条件交付前,后端具备 exact replay 能力不等于 UI 已经端到端防重复。 +- 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 固定本轮绝对观察窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。快照不得包含 Data URL、Blob URL 或 signed URL;POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 +- 完美像素 unknown-result 的 verdict 只来自项目 GET:找到稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;两者都不存在或 dialog 仍未收口时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败。 +- 完美像素前端第三批负责恢复而不负责重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。v1 operation 的 `reconcileUntil - submittedAt` 不得超过固定 240 秒,跨设备时钟偏差也只能让当前页面再观察至多一个完整窗口,不能信任远未来时间戳形成长期轮询。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 清理;TTL 只兼容没有有效 operation 快照的历史 inline 孤儿,清理时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入第二批的 exact replay。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4be842ace..4503f3952 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -249,7 +249,7 @@ npm run check:server-rs-ddd ## 外部服务与资产 - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 -- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。进入持久化后沿用既有跨 OSS 与 SpacetimeDB 的非事务边界,依次确认 PNG / asset object、project resource、账号素材和 completion;后段失败可能保留此前已经确认的对象或记录,不做破坏性删除补偿,也不自动重放 unsafe POST。排障按响应或日志中的 `task_id / object_key / resource_id` 重新读取权威项目与素材快照;跨系统单事务 completion 留待独立 procedure 方案收口。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个 preflight 声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。事务失败后可能留下无引用 OSS object,当前不做破坏性删除补偿或历史孤儿清理。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index dcfbeb626..6312c0811 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -15,8 +15,8 @@ use axum::{ http::{HeaderValue, StatusCode}, }; use module_assets::{ - AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, - generate_asset_object_id, + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, + build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, @@ -38,7 +38,9 @@ use shared_contracts::assets::{ }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ - EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, + EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, + EditorPixelArtResultPersistRecordInput, EditorSpritesheetSliceBatchPersistRecordInput, + EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, @@ -168,16 +170,15 @@ const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(3 /// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、 /// 中途放弃只会留下孤儿对象。 const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60); -/// 中文注释:完美像素持久化是非事务的(OSS + asset object → project resource → -/// editor asset → canvas completion)。后段失败时前段记录会保留,而 completion 走 -/// `map_editor_project_error` 会被映射成 403 / 404 / 409 / 400,客户端从状态码分不出 -/// 失败发生在持久化之前还是之后。这个 detail 字段就是那个信号:只在第一次 OSS PUT -/// 之后的失败路径上置位,客户端据此决定是否先 GET 权威快照对账,而不是直接标失败诱使 -/// 用户重试再造一份对象、资源和素材。 +/// 中文注释:完美像素的 OSS PUT/HEAD 位于数据库事务外;随后 asset object、project +/// resource、editor asset 与可选 canvas completion 由单个 SpacetimeDB procedure 原子提交。 +/// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于 +/// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; +const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1"; static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, @@ -4528,20 +4529,61 @@ struct EditorBackgroundRemovalImage { fn validate_editor_pixel_art_snap_canvas_completion( completion: &EditorCanvasGenerationCompletionRequest, ) -> Result<(), AppError> { - if normalized_canvas_completion_dialog_id(completion).is_some() { - return Ok(()); + let Some(dialog_id) = normalized_canvas_completion_dialog_id(completion) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素必须关联有效的画布生成占位。", + })), + ); + }; + if dialog_id.contains('\0') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素画布生成占位 ID 不得包含 NUL。", + })), + ); } - Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "pixel-art-snapper", - "field": "canvasCompletion.dialogId", - "message": "完美像素必须关联有效的画布生成占位。", - })), - ) + if completion.title.trim().is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.title", + "message": "完美像素画布结果标题不能为空。", + })), + ); + } + let placeholder = &completion.placeholder; + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.placeholder", + "message": "完美像素画布占位的坐标与尺寸必须有效。", + })), + ); + } + Ok(()) } fn validate_editor_pixel_art_snap_placeholder_exists( layers: &Value, + resources: &[EditorProjectResourcePayload], + owner_user_id: &str, + project_id: &str, completion: &EditorCanvasGenerationCompletionRequest, ) -> Result<(), AppError> { let dialog_id = normalized_canvas_completion_dialog_id(completion).ok_or_else(|| { @@ -4557,6 +4599,27 @@ fn validate_editor_pixel_art_snap_placeholder_exists( }) { return Ok(()); } + // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 + // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure + // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 + let expected_task_id = format!("pixel-art-snap-{dialog_id}"); + let expected_resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + dialog_id.as_str(), + "project-resource", + ) + ); + if resources.iter().any(|resource| { + resource.resource_id == expected_resource_id + && resource.owner_user_id == owner_user_id + && resource.project_id == project_id + && resource.task_id.as_deref() == Some(expected_task_id.as_str()) + }) { + return Ok(()); + } Err( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "pixel-art-snapper", @@ -4818,6 +4881,127 @@ fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) } +#[derive(Debug, PartialEq, Eq)] +struct EditorPixelArtPersistenceIdentity { + operation_id: String, + operation_fingerprint: String, + task_id: String, + asset_object_id: String, + resource_id: String, + asset_id: String, +} + +fn canonicalize_editor_json_value(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .map(canonicalize_editor_json_value) + .collect(), + ), + Value::Object(object) => { + let sorted = object.into_iter().collect::>(); + Value::Object( + sorted + .into_iter() + .map(|(key, value)| (key, canonicalize_editor_json_value(value))) + .collect(), + ) + } + other => other, + } +} + +fn editor_pixel_art_sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn editor_pixel_art_stable_record_suffix( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, +) -> String { + let input = format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ); + editor_pixel_art_sha256_hex(input.as_bytes())[..32].to_string() +} + +#[allow(clippy::too_many_arguments)] +fn build_editor_pixel_art_persistence_identity( + owner_user_id: &str, + project_id: &str, + dialog_id: &str, + source_object_key: &str, + source_image_sha256: &str, + output_image_sha256: &str, + source_resource_id: Option<&str>, + asset_kind: Option<&str>, + asset_folder_id: &str, + asset_label: &str, + generation_inputs: Option<&Value>, + canvas_completion: &EditorCanvasGenerationCompletionRequest, +) -> Result { + let operation_id = dialog_id.to_string(); + let fingerprint_payload = canonicalize_editor_json_value(json!({ + "algorithmVersion": EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, + "ownerUserId": owner_user_id, + "projectId": project_id, + "dialogId": dialog_id, + "sourceObjectKey": source_object_key, + "sourceImageSha256": source_image_sha256, + "outputImageSha256": output_image_sha256, + "sourceResourceId": source_resource_id, + "assetKind": asset_kind, + "assetFolderId": asset_folder_id, + "assetLabel": asset_label, + "generationInputs": generation_inputs, + "canvasCompletion": { + "dialogId": dialog_id, + "title": canvas_completion.title.trim(), + "placeholder": { + "x": canvas_completion.placeholder.x, + "y": canvas_completion.placeholder.y, + "width": canvas_completion.placeholder.width, + "height": canvas_completion.placeholder.height, + "originalWidth": canvas_completion.placeholder.original_width, + "originalHeight": canvas_completion.placeholder.original_height, + }, + }, + })); + let fingerprint_bytes = serde_json::to_vec(&fingerprint_payload).map_err(|error| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("完美像素幂等指纹无法序列化:{error}"), + ) + })?; + let operation_fingerprint = editor_pixel_art_sha256_hex(fingerprint_bytes.as_slice()); + let stable_record_id = |prefix: &str, record_kind: &str| { + format!( + "{prefix}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + operation_id.as_str(), + record_kind, + ) + ) + }; + let asset_object_id = stable_record_id("assetobj_", "asset-object"); + let resource_id = stable_record_id(EDITOR_RESOURCE_ID_PREFIX, "project-resource"); + let asset_id = stable_record_id(EDITOR_ASSET_ID_PREFIX, "asset"); + + Ok(EditorPixelArtPersistenceIdentity { + operation_id, + operation_fingerprint, + task_id: format!("pixel-art-snap-{dialog_id}"), + asset_object_id, + resource_id, + asset_id, + }) +} + pub async fn snap_editor_image_to_pixel_art( State(state): State, Extension(request_context): Extension, @@ -4832,6 +5016,10 @@ pub async fn snap_editor_image_to_pixel_art( // 保持一致:在任何 IO 之前。 payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = payload + .generation_inputs + .take() + .map(canonicalize_editor_json_value); let started_at = Instant::now(); // 中文注释:处理预算必须从进入 handler 起算,而不是等下载完成后才起算。inline HTTP // 请求的 RequestContext 默认没有 external_call_deadline(只有队列 worker 会设), @@ -4885,6 +5073,9 @@ pub async fn snap_editor_image_to_pixel_art( let project = editor_project_payload_from_record(project); validate_editor_pixel_art_snap_placeholder_exists( &project.layers, + project.resources.as_slice(), + owner_user_id.as_str(), + project_id.as_str(), &payload.canvas_completion, )?; let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { @@ -4930,6 +5121,7 @@ pub async fn snap_editor_image_to_pixel_art( ) .await?; validate_editor_pixel_art_static_raster(&source_image)?; + let source_image_sha256 = editor_pixel_art_sha256_hex(source_image.bytes.as_slice()); // 中文注释:传已派生的绝对 deadline 而不是原始 request_deadline。 // resolve_editor_pixel_art_processing_deadline 取 min,所以内层拿到的仍是这一份, // 下载耗掉的时间会如实从规整预算里扣除,而不是让规整重新获得完整 30s。 @@ -4950,132 +5142,167 @@ pub async fn snap_editor_image_to_pixel_art( ) })?; - let task_id = build_prefixed_uuid_id("pixel-art-snap-"); - let response_task_id = task_id.clone(); + let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); + let dialog_id = + normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; + let asset_folder_id = normalize_generated_asset_folder_id( + resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id.take()), + owner_user_id.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + "完美像素结果缺少账号素材目录。", + ) + })?; + let asset_label = resolve_editor_generated_asset_label(payload.asset_label.take(), "完美像素"); + let persistence_identity = build_editor_pixel_art_persistence_identity( + owner_user_id.as_str(), + project_id.as_str(), + dialog_id.as_str(), + source_object_key.as_str(), + source_image_sha256.as_str(), + output_image_sha256.as_str(), + source_resource_id.as_deref(), + asset_kind.as_deref(), + asset_folder_id.as_str(), + asset_label.as_str(), + payload.generation_inputs.as_ref(), + &payload.canvas_completion, + )?; + let response_task_id = persistence_identity.task_id.clone(); + let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.take())?; + let canvas_completion = EditorPixelArtCanvasCompletionRecordInput { + dialog_id, + title: payload.canvas_completion.title.trim().to_string(), + placeholder: EditorPixelArtCanvasPlaceholderRecordInput { + x: payload.canvas_completion.placeholder.x, + y: payload.canvas_completion.placeholder.y, + width: payload.canvas_completion.placeholder.width, + height: payload.canvas_completion.placeholder.height, + original_width: payload.canvas_completion.placeholder.original_width, + original_height: payload.canvas_completion.placeholder.original_height, + }, + }; // 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD - // 各自独立计时,加上三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上, + // 各自独立计时,加上多次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上, // 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在 // 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。 // - // 这里独立起算 60 秒,不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,不该因为 - // 下载慢就被砍预算,中途放弃只会留下孤儿。处理 30 秒 + 持久化 60 秒 = 服务端最坏 90 秒, - // 落在客户端 120 秒内并留出 30 秒余量给网络往返。 + // 这里独立起算 60 秒,不与处理预算取 min。OSS PUT/HEAD 后只调用一次原子 procedure; + // procedure future 被本地 timeout 丢弃并不能撤销远端事务,因此同一 dialog 的 operation、 + // record IDs 与 fingerprint 都保持稳定,让响应丢失后的同输入重放只能得到同一份结果。 let persistence_started_at = Instant::now(); let persistence_deadline = persistence_started_at .checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION) .unwrap_or(persistence_started_at); - let (persisted, image_src, resource, asset, completed_project) = tokio::time::timeout_at( + let persistence_task_id = response_task_id.clone(); + let persisted = tokio::time::timeout_at( tokio::time::Instant::from_std(persistence_deadline), async move { - // 中文注释:尺寸已经在上一步取完,snapped_image 之后不再使用,所以按值移交给 _owned - // 版本。借用版会在内部 `image.bytes.clone()` 复制一整份输出图片;完美像素是同步路径, - // 那份复制会和原图在同一时刻共存。 - let persisted = persist_editor_generated_image_owned( + let fingerprint_file_stem = format!( + "perfect-pixel-{}", + persistence_identity.operation_fingerprint + ); + let mut uploaded = upload_editor_generated_image_object_data( &state, owner_user_id.as_str(), - task_id.as_str(), - snapped_image, + persistence_identity.task_id.as_str(), + GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()), + bytes: snapped_image.bytes, + }, "完美像素", - None, EDITOR_PIXEL_ART_SNAP_ASSET_KIND, "pixel-art-snaps", - "perfect-pixel", + fingerprint_file_stem.as_str(), "result", "genarrative", + Some(persistence_identity.asset_object_id.clone()), ) .await?; - let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); - let generated_asset = persist_editor_generated_asset( - &state, - PersistEditorGeneratedAssetInput { - project_id: Some(project_id.clone()), + let completed_at_micros = current_utc_micros(); + uploaded.asset_object.content_type = Some("image/png".to_string()); + uploaded.asset_object.content_hash = Some(output_image_sha256); + uploaded.asset_object.updated_at_micros = completed_at_micros; + let image_src = editor_media_src_from_object_key(uploaded.object_key.as_str()); + state + .spacetime_client() + .persist_editor_pixel_art_result(EditorPixelArtResultPersistRecordInput { owner_user_id: owner_user_id.clone(), - folder_id: resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id), - label: resolve_editor_generated_asset_label(payload.asset_label, "完美像素"), - image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - asset_object_id: Some(persisted.asset_object_id.clone()), - width, - height, - prompt: "完美像素".to_string(), - actual_prompt: None, - model: EDITOR_PIXEL_ART_SNAP_MODEL.to_string(), - provider: EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string(), - task_id: task_id.clone(), - group_task_id: None, - group_task_expected_asset_count: None, - source_resource_id, - asset_kind, - generation_inputs: payload.generation_inputs, - thumbnail_src: None, - generation_cost_mud_points: 0, - }, - ) - .await - .map_err(|error| { - tracing::warn!( - provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, - task_id = %task_id, - object_key = %persisted.object_key, - error = %error, - "editor_pixel_art_snap_asset_persistence_failed_after_object_put" - ); - error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?; - let resource = generated_asset.resource.ok_or_else(|| { - tracing::warn!( - provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, - task_id = %task_id, - object_key = %persisted.object_key, - "editor_pixel_art_snap_project_resource_missing_after_object_put" - ); - editor_pixel_art_snap_failure( - StatusCode::INTERNAL_SERVER_ERROR, - "完美像素结果未创建项目资源。", - ) - .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?; - let asset = generated_asset.asset.ok_or_else(|| { - tracing::warn!( - provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, - task_id = %task_id, - object_key = %persisted.object_key, - resource_id = %resource.resource_id, - "editor_pixel_art_snap_account_asset_missing_after_object_put" - ); - editor_pixel_art_snap_failure( - StatusCode::INTERNAL_SERVER_ERROR, - "完美像素结果未创建账号素材。", - ) - .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?; - // 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经 - // 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。 - let completed_project = complete_editor_canvas_generation( - &state, - owner_user_id.as_str(), - Some(project_id.as_str()), - Some(&payload.canvas_completion), - Some(&resource), - ) - .await - .map_err(|error| { - tracing::warn!( - provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, - task_id = %task_id, - object_key = %persisted.object_key, - resource_id = %resource.resource_id, - error = %error, - "editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence" - ); - error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?; - Ok::<_, AppError>((persisted, image_src, resource, asset, completed_project)) + project_id: project_id.clone(), + operation_id: persistence_identity.operation_id, + operation_fingerprint: persistence_identity.operation_fingerprint, + asset_object: uploaded.asset_object, + project_resource: EditorProjectResourceCreateRecordInput { + resource_id: persistence_identity.resource_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + asset_object_id: Some(persistence_identity.asset_object_id.clone()), + image_src: image_src.clone(), + object_key: Some(uploaded.object_key.clone()), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: asset_kind.clone(), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: completed_at_micros, + }, + asset: EditorAssetCreateRecordInput { + asset_id: persistence_identity.asset_id, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label, + asset_object_id: Some(persistence_identity.asset_object_id), + image_src, + object_key: Some(uploaded.object_key), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id), + asset_kind, + generation_inputs_json, + source_resource_id: Some(persistence_identity.resource_id), + now_micros: completed_at_micros, + thumbnail_src: None, + generation_cost_mud_points: 0, + group_task_id: None, + group_task_expected_asset_count: None, + }, + canvas_completion, + completed_at_micros, + }) + .await + .map_err(|error| { + tracing::warn!( + provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, + task_id = %persistence_task_id, + error = %error, + "editor_pixel_art_snap_atomic_persistence_failed_after_object_put" + ); + map_editor_project_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + }) }, ) .await - // 中文注释:超时发生在 PUT 已经发出之后,对象可能已经落盘也可能没有——正是契约要覆盖的 - // 未知结果,必须带标记,否则客户端会把它当确定失败、直接诱使用户重试再造一份。 + // 中文注释:超时发生在 PUT 已经发出之后,对象可能已经落盘,也可能 procedure 已在远端 + // 原子提交但响应尚未回来。两者都属于未知结果,必须保留标记让客户端进入权威对账。 .map_err(|_| { editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, @@ -5084,12 +5311,17 @@ pub async fn snap_editor_image_to_pixel_art( .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })??; + let image_src = editor_media_src_from_object_key(persisted.asset_object.object_key.as_str()); + let resource = editor_project_resource_payload_from_record(persisted.project_resource); + let asset = editor_asset_payload_from_record(persisted.asset); + let completed_project = persisted.project.map(editor_project_payload_from_record); + Ok(json_success_body( Some(&request_context), EditorPixelArtSnapResponse { image_src, - object_key: persisted.object_key, - asset_object_id: persisted.asset_object_id, + object_key: persisted.asset_object.object_key, + asset_object_id: persisted.asset_object.asset_object_id, width, height, source_type: "generated", @@ -9457,6 +9689,11 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +struct UploadedEditorGeneratedImageObject { + object_key: String, + asset_object: AssetObjectUpsertInput, +} + struct PersistEditorProviderSourceResourceInput { project_id: Option, owner_user_id: String, @@ -9552,6 +9789,53 @@ async fn persist_editor_generated_image_data( slot: &str, provider: &str, ) -> Result { + let uploaded = upload_editor_generated_image_object_data( + state, + owner_user_id, + task_id, + image, + prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + None, + ) + .await?; + let asset_object = state + .spacetime_client() + .confirm_asset_object(uploaded.asset_object) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; + + Ok(PersistedEditorGeneratedImage { + object_key: uploaded.object_key, + asset_object_id: asset_object.asset_object_id, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn upload_editor_generated_image_object_data( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + prompt: &str, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, + asset_object_id: Option, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", @@ -9619,44 +9903,31 @@ async fn persist_editor_generated_image_data( .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; let now_micros = current_utc_micros(); - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key.clone(), - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(persisted_mime_type)), - head.content_length, - // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider - // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 - Some(prompt.to_string()), - asset_kind.to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(task_id.to_string()), - now_micros, - ) - .map_err(|error| { - map_editor_asset_field_error(error) - .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?, - ) - .await - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) - .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) - })?; + let asset_object = build_asset_object_upsert_input( + asset_object_id.unwrap_or_else(|| generate_asset_object_id(now_micros)), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider + // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 + Some(prompt.to_string()), + asset_kind.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(task_id.to_string()), + now_micros, + ) + .map_err(|error| { + map_editor_asset_field_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; - Ok(PersistedEditorGeneratedImage { + Ok(UploadedEditorGeneratedImageObject { object_key: head.object_key, - asset_object_id: asset_object.asset_object_id, + asset_object, }) } @@ -10452,14 +10723,16 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("不存在") => { - AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + SpacetimeClientError::Procedure(message) + if message.contains("版本冲突") || message.contains("幂等") => + { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => { - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + SpacetimeClientError::Procedure(message) if message.contains("不存在") => { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-project", "message": message, })) @@ -10749,6 +11022,28 @@ mod tests { assert_eq!(error.status_code(), StatusCode::CONFLICT); } + #[test] + fn editor_project_idempotency_conflicts_map_to_http_conflict() { + for message in [ + "完美像素 asset_object 幂等键已被其他内容占用", + "完美像素幂等冲突:dialog 指向的结果图层不存在", + ] { + let error = + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + let missing_source = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素来源项目资源不存在".to_string(), + )); + assert_eq!(missing_source.status_code(), StatusCode::NOT_FOUND); + + let invalid_fingerprint = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string(), + )); + assert_eq!(invalid_fingerprint.status_code(), StatusCode::BAD_REQUEST); + } + #[test] fn editor_project_layout_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ @@ -12527,6 +12822,88 @@ mod tests { ); } + #[test] + fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() { + for (record_kind, expected) in [ + ("asset-object", "8a264e1086ee3d6878d753aec254e0a5"), + ("project-resource", "9eee84b77e8828ca8f5042919198ac1c"), + ("asset", "c7e7e66538f4613226e68001b8408104"), + ] { + assert_eq!( + editor_pixel_art_stable_record_suffix( + "owner-1", + "project-1", + "dialog-1", + record_kind, + ), + expected, + "API 与 SpacetimeDB module 必须共享逐字相同的稳定 ID 算法", + ); + } + let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ + "dialogId": "generation-dialog-7", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + let first_inputs: Value = serde_json::from_str(r#"{"z":1,"nested":{"b":2,"a":1}}"#) + .expect("first generation inputs should parse"); + let reordered_inputs: Value = serde_json::from_str(r#"{"nested":{"a":1,"b":2},"z":1}"#) + .expect("reordered generation inputs should parse"); + let build = |source_hash: &str, + inputs: &Value, + completion: &EditorCanvasGenerationCompletionRequest| { + build_editor_pixel_art_persistence_identity( + "user-1", + "project-1", + "generation-dialog-7", + "generated/editor/source.png", + source_hash, + "output-sha256", + Some("source-resource-1"), + Some("character"), + "user-1:asset-folder:project", + "完美像素", + Some(inputs), + completion, + ) + .expect("pixel-art identity should build") + }; + + let first = build("source-sha256", &first_inputs, &completion); + let reordered = build("source-sha256", &reordered_inputs, &completion); + assert_eq!(first, reordered, "JSON key order must not alter identity"); + assert_eq!(first.operation_id, "generation-dialog-7"); + assert_eq!(first.task_id, "pixel-art-snap-generation-dialog-7"); + assert!(first.asset_object_id.starts_with("assetobj_")); + assert!(first.resource_id.starts_with(EDITOR_RESOURCE_ID_PREFIX)); + assert!(first.asset_id.starts_with(EDITOR_ASSET_ID_PREFIX)); + + let mut padded_completion = completion.clone(); + padded_completion.dialog_id = Some(" generation-dialog-7 ".to_string()); + padded_completion.title = " 完美像素 ".to_string(); + let padded = build("source-sha256", &first_inputs, &padded_completion); + assert_eq!( + first.operation_fingerprint, padded.operation_fingerprint, + "completion fields interpreted with trim semantics must fingerprint identically" + ); + + let drifted = build("different-source-sha256", &first_inputs, &completion); + assert_eq!(first.operation_id, drifted.operation_id); + assert_eq!(first.task_id, drifted.task_id); + assert_eq!(first.asset_object_id, drifted.asset_object_id); + assert_eq!(first.resource_id, drifted.resource_id); + assert_eq!(first.asset_id, drifted.asset_id); + assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint); + } + #[test] fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() { let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( @@ -12594,7 +12971,7 @@ mod tests { #[test] fn explicit_pixel_art_snap_requires_non_empty_canvas_dialog_id() { - for dialog_id in [None, Some(" ")] { + for dialog_id in [None, Some(" "), Some("dialog\0id")] { let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ "dialogId": dialog_id, @@ -12620,6 +12997,45 @@ mod tests { } } + #[test] + fn explicit_pixel_art_snap_validates_completion_before_persistence() { + let mut completion: EditorCanvasGenerationCompletionRequest = + serde_json::from_value(json!({ + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + + completion.title = " ".to_string(); + let blank_title = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("blank result title must fail before persistence"); + assert_eq!( + blank_title + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.title")) + ); + + completion.title = "完美像素".to_string(); + completion.placeholder.width = 0.0; + let invalid_placeholder = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("invalid placeholder must fail before persistence"); + assert_eq!( + invalid_placeholder + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.placeholder")) + ); + } + #[test] fn explicit_pixel_art_snap_requires_a_persisted_placeholder_before_processing() { let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ @@ -12635,8 +13051,14 @@ mod tests { } })) .expect("completion request should deserialize"); - let missing = validate_editor_pixel_art_snap_placeholder_exists(&json!([]), &completion) - .expect_err("missing server placeholder should fail before processing"); + let missing = validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[], + "user-1", + "editor-project-1", + &completion, + ) + .expect_err("missing server placeholder should fail before processing"); assert_eq!(missing.status_code(), StatusCode::CONFLICT); let layers = json!([{ @@ -12646,7 +13068,40 @@ mod tests { "placeholder": completion.placeholder.clone() } }]); - assert!(validate_editor_pixel_art_snap_placeholder_exists(&layers, &completion).is_ok()); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &layers, + &[], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok() + ); + + let mut replay_resource = + editor_project_resource_for_canvas_test("placeholder", "image", 128, 128); + replay_resource.resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + "user-1", + "editor-project-1", + "dialog-pixel-art", + "project-resource", + ) + ); + replay_resource.task_id = Some("pixel-art-snap-dialog-pixel-art".to_string()); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[replay_resource], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok(), + "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" + ); } #[test] @@ -12689,9 +13144,8 @@ mod tests { // 钉死:任何把 persist 挪到 timeout_at 之前的改动都会失败。 "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION", "tokio::time::timeout_at(", - "persist_editor_generated_image", - "persist_editor_generated_asset", - "complete_editor_canvas_generation", + "upload_editor_generated_image_object_data", + ".persist_editor_pixel_art_result(", // 中文注释:持久化超时同样发生在 PUT 之后,属于未知结果,必须带标记。 "完美像素结果持久化超出处理预算。", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", @@ -12701,7 +13155,7 @@ mod tests { source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", - "persist_editor_generated_image_owned(", + ".persist_editor_pixel_art_result(", 1, ); // 中文注释:全账号扫描各只能出现一次。此前解析链路是三个各自取数的函数串联 @@ -12732,20 +13186,16 @@ mod tests { "resolve_editor_reference_object_key_for_owner(state", ], ); - // 中文注释:第一次 OSS PUT 之后的每条失败路径都必须带 resultPersistenceStarted。 - // 服务端持久化非事务,completion 失败经 map_editor_project_error 会变成 4xx, - // 客户端从状态码分不出失败在持久化前还是后;漏标一处,那条路径上的失败就会被前端 - // 判成「结果已知」,用户重试再造一份对象、资源和素材。五处分别对应:账号素材持久化 - // 失败、项目资源缺失、账号素材缺失、画布回填失败,以及持久化整段超出独立预算—— - // 最后一处的超时同样发生在 PUT 之后,对象可能已落盘也可能没有,正是契约要覆盖的 - // 未知结果。(helper 内部另有四处,由 editor_matting_releases_source_buffers_at_oss_boundaries - // 单独钉住;调用方拿到的是同一个 AppError,分不出内部走到了哪一步。) + // 中文注释:第一次 OSS PUT 之后的 procedure 错误与整段 timeout 都必须带 + // resultPersistenceStarted。远端 procedure 是单事务,但本地 future 被 timeout/drop + // 不能撤销远端调用,因此这两条仍是未知结果;PUT/HEAD/helper 构造错误由 helper 测试 + // 单独钉住。 assert_function_occurrence_count( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", - 5, + 2, ); assert_function_contains_in_order( source, @@ -12754,7 +13204,7 @@ mod tests { &[ // 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯 // 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。 - "persist_editor_generated_image_owned(", + "upload_editor_generated_image_object_data(", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", ], ); @@ -12767,12 +13217,18 @@ mod tests { "external_generation_job", "queue_state", "snap_editor_pixel_art_or_original", + "confirm_asset_object(", + "persist_editor_generated_asset(", + "complete_editor_canvas_generation(", + "create_editor_project_resource(", + "create_editor_asset(", // 中文注释:下载必须走带 deadline 的包装。退回裸调用会让 OSS GET 重新 // 落在 30s 预算之外,请求可以持着最多 32 MiB 无限期排队等 CPU 许可。 "download_editor_persisted_image_object(&state", - // 中文注释:持久化必须走按值移交的 _owned 版本。借用版内部会 clone 一整份 - // 输出图片,在同步路径上和原图同时驻留内存。 + // 中文注释:完美像素必须走 upload-only helper;这些旧包装会在新原子 + // procedure 之前单独 confirm asset object,重新制造 object-only 部分状态。 "persist_editor_generated_image(", + "persist_editor_generated_image_owned(", // 中文注释:取输出尺寸只读 PNG 头。整图解码发生在 CPU 许可之外, // 对 8294400 像素上限的输出要多分配约 33 MiB,且不换来任何保证。 "image::load_from_memory", @@ -16515,10 +16971,27 @@ mod tests { "async fn persist_editor_provider_source_image", &["reqwest::Client::new()"], ); + // 中文注释:旧包装仍在 upload-only helper 之后 confirm asset object,供兄弟链路 + // 保持原行为;完美像素只走 upload-only helper,再把 object/resource/asset/canvas + // 交给单个原子 procedure。两层边界必须分别钉住,防止 confirm 再次溜回新链路。 + assert_function_contains_in_order( + source, + "async fn persist_editor_generated_image_data", + "async fn upload_editor_generated_image_object_data", + &[ + "upload_editor_generated_image_object_data", + ".confirm_asset_object(uploaded.asset_object)", + ], + ); + assert_function_not_contains( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + &["confirm_asset_object("], + ); // 中文注释:调用方拿到的是同一个 AppError,无法自行判断本函数内部走到了哪一步, - // 所以「失败发生在持久化开始之后」只能在这里标。四处分别是 PUT(响应丢失时字节 - // 可能已落盘)、HEAD、asset object 入参构造、confirm_asset_object。漏标任意一处, - // 客户端就会把「对象已在 OSS」的失败当成「什么都没发生」,重试造出孤儿对象。 + // 所以「失败发生在持久化开始之后」只能在这里标。整个旧包装的四处分别是 PUT + // (响应丢失时字节可能已落盘)、HEAD、asset object 入参构造与旧 confirm。 assert_function_occurrence_count( source, "async fn persist_editor_generated_image_data", @@ -16526,6 +16999,13 @@ mod tests { "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 4, ); + assert_function_occurrence_count( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 3, + ); // 中文注释:标记必须在第一次 PUT 之后才出现。prepare_put_object 与 OSS 未配置这两处 // 失败都在 PUT 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。 assert_function_contains_in_order( diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 2e991dc7c..d987fe519 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -1,5 +1,96 @@ 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 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, Copy, Debug, PartialEq, Eq)] +pub enum EditorPixelArtResultPersistRecordStatus { + Applied, + DialogMissing, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPersistRecord { + pub status: EditorPixelArtResultPersistRecordStatus, + pub asset_object: module_assets::AssetObjectUpsertSnapshot, + pub project_resource: EditorProjectResourceRecord, + pub asset: EditorAssetRecord, + pub project: Option, +} + +impl From + 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 + 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 + 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 +148,31 @@ impl From } impl SpacetimeClient { + pub async fn persist_editor_pixel_art_result( + &self, + input: EditorPixelArtResultPersistRecordInput, + ) -> Result { + 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 +1097,58 @@ impl SpacetimeClient { } } +fn map_editor_pixel_art_result_persist_result( + result: crate::module_bindings::EditorPixelArtResultPersistResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let status = match result + .status + .ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少状态"))? + { + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied => { + EditorPixelArtResultPersistRecordStatus::Applied + } + crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing => { + EditorPixelArtResultPersistRecordStatus::DialogMissing + } + crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied => { + EditorPixelArtResultPersistRecordStatus::AlreadyApplied + } + }; + 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 { + status, + 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 { @@ -1113,3 +1281,174 @@ 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_preserves_status_and_accepts_missing_project() { + let cases = [ + ( + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied, + EditorPixelArtResultPersistRecordStatus::Applied, + ), + ( + crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing, + EditorPixelArtResultPersistRecordStatus::DialogMissing, + ), + ( + crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied, + EditorPixelArtResultPersistRecordStatus::AlreadyApplied, + ), + ]; + + for (binding_status, expected_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.status, expected_status); + 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()); + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e0100906a..1a46a742a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -297,6 +297,11 @@ 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_project_create_input_type; pub mod editor_project_delete_input_type; pub mod editor_project_delete_procedure_result_type; @@ -475,6 +480,7 @@ 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; @@ -1120,6 +1126,11 @@ 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_project_create_input_type::EditorProjectCreateInput; pub use editor_project_delete_input_type::EditorProjectDeleteInput; pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult; @@ -1298,6 +1309,7 @@ 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::*; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs new file mode 100644 index 000000000..bf42b848c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs new file mode 100644 index 000000000..560b919f2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs new file mode 100644 index 000000000..9ee40e575 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs new file mode 100644 index 000000000..e9b04d65d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs @@ -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, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorPixelArtResultPersistResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs new file mode 100644 index 000000000..942e61bf0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs @@ -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)] +#[derive(Copy, Eq, Hash)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + + DialogMissing, + + AlreadyApplied, +} + +impl __sdk::InModule for EditorPixelArtResultPersistStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..c25ee2e1c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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_result_persist_input_type::EditorPixelArtResultPersistInput; +use super::editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PersistEditorPixelArtResultAndReturnArgs { + pub input: EditorPixelArtResultPersistInput, +} + +impl __sdk::InModule for PersistEditorPixelArtResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `persist_editor_pixel_art_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait persist_editor_pixel_art_result_and_return { + fn persist_editor_pixel_art_result_and_return(&self, input: EditorPixelArtResultPersistInput) { + self.persist_editor_pixel_art_result_and_return_then(input, |_, _| {}); + } + + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures { + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>( + "persist_editor_pixel_art_result_and_return", + PersistEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 5df4afb18..b1ef33fcc 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -882,6 +882,54 @@ pub struct EditorSpritesheetSliceBatchPersistResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasPlaceholderInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasCompletionInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderInput, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +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, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + DialogMissing, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPersistResult { + pub ok: bool, + pub status: Option, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetUpdateInput { pub asset_id: String, @@ -1528,6 +1576,34 @@ pub fn persist_editor_spritesheet_slice_batch_and_return( } } +#[spacetimedb::procedure] +pub fn persist_editor_pixel_art_result_and_return( + ctx: &mut ProcedureContext, + input: EditorPixelArtResultPersistInput, +) -> EditorPixelArtResultPersistResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| persist_editor_pixel_art_result(tx, caller, input.clone())) { + Ok(persisted) => EditorPixelArtResultPersistResult { + ok: true, + status: Some(persisted.status), + asset_object: Some(persisted.asset_object), + project_resource: Some(persisted.project_resource), + asset: Some(persisted.asset), + project: persisted.project, + error_message: None, + }, + Err(message) => EditorPixelArtResultPersistResult { + ok: false, + status: None, + asset_object: None, + project_resource: None, + asset: None, + project: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn update_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -2513,6 +2589,794 @@ fn persist_editor_spritesheet_slice_batch( Ok(persisted_items) } +struct EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus, + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + project: Option, +} + +enum EditorPixelArtExistingRecords { + New, + Existing { + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + }, +} + +enum EditorPixelArtCanvasCompletionPlan { + Apply { + canvas: EditorCanvas, + layers_json: String, + }, + DialogMissing, + AlreadyApplied, +} + +#[derive(Debug)] +enum EditorPixelArtCanvasLayoutPlan { + Apply(String), + DialogMissing, + AlreadyApplied, +} + +fn persist_editor_pixel_art_result( + ctx: &ReducerContext, + caller: Identity, + mut input: EditorPixelArtResultPersistInput, +) -> Result { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let project_id = normalize_required(&input.project_id, "project_id")?; + let operation_id = normalize_required(&input.operation_id, "operation_id")?; + let operation_fingerprint = + normalize_required(&input.operation_fingerprint, "operation_fingerprint")?; + let dialog_id = normalize_required( + &input.canvas_completion.dialog_id, + "canvas_completion.dialog_id", + )?; + + if input.asset.folder_id.trim() == EDITOR_ASSET_DEFAULT_FOLDER_ID { + input.asset.folder_id = default_asset_folder_id(owner_user_id.as_str()); + } + validate_editor_pixel_art_result_input( + ctx, + &input, + owner_user_id.as_str(), + project_id.as_str(), + operation_id.as_str(), + operation_fingerprint.as_str(), + dialog_id.as_str(), + )?; + + let existing = load_editor_pixel_art_existing_records(ctx, &input)?; + let (asset_object, project_resource, asset) = match existing { + EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + } => { + let project = resolve_editor_pixel_art_replay_project_snapshot( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + return Ok(EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus::AlreadyApplied, + asset_object, + project_resource, + asset, + project, + }); + } + EditorPixelArtExistingRecords::New => { + let asset_object = + crate::asset_metadata::upsert_asset_object(ctx, input.asset_object.clone())?; + let project_resource = + create_editor_project_resource(ctx, input.project_resource.clone())?; + if project_resource.resource_id.trim() != input.project_resource.resource_id.trim() { + return Err("完美像素项目资源未按稳定幂等 ID 创建".to_string()); + } + let asset = create_editor_asset(ctx, caller, input.asset.clone())?; + if asset.asset_id.trim() != input.asset.asset_id.trim() { + return Err("完美像素账号素材未按稳定幂等 ID 创建".to_string()); + } + (asset_object, project_resource, asset) + } + }; + + let completion_plan = plan_editor_pixel_art_canvas_completion( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + let (status, project) = match completion_plan { + EditorPixelArtCanvasCompletionPlan::DialogMissing => { + (EditorPixelArtResultPersistStatus::DialogMissing, None) + } + EditorPixelArtCanvasCompletionPlan::AlreadyApplied => { + return Err(editor_pixel_art_idempotency_conflict( + "画布已有稳定结果,但对应 object/resource/asset 记录此前不存在", + )); + } + EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + } => { + persist_editor_project_layout_v2( + ctx, + EditorProjectLayoutSaveV2Input { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + viewport: EditorProjectViewportSnapshot { + x: canvas.viewport_x, + y: canvas.viewport_y, + scale: canvas.viewport_scale, + }, + layers_json, + expected_revision: canvas.revision, + updated_at_micros: input.completed_at_micros, + }, + )?; + ( + EditorPixelArtResultPersistStatus::Applied, + Some(build_project_snapshot(ctx, project_id.as_str())?), + ) + } + }; + + Ok(EditorPixelArtResultPersisted { + status, + asset_object, + project_resource, + asset, + project, + }) +} + +fn validate_editor_pixel_art_result_input( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, + owner_user_id: &str, + project_id: &str, + operation_id: &str, + operation_fingerprint: &str, + dialog_id: &str, +) -> Result<(), String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let asset_object_id = normalize_required( + &input.asset_object.asset_object_id, + "asset_object.asset_object_id", + )?; + let object_key = normalize_required(&input.asset_object.object_key, "asset_object.object_key")?; + let resource_id = normalize_required( + &input.project_resource.resource_id, + "project_resource.resource_id", + )?; + let asset_id = normalize_required(&input.asset.asset_id, "asset.asset_id")?; + let task_id = normalize_optional(input.asset_object.source_job_id.clone()) + .ok_or_else(|| "完美像素 asset_object 缺少稳定 task_id".to_string())?; + let resource_task_id = normalize_optional(input.project_resource.task_id.clone()); + let asset_task_id = normalize_optional(input.asset.task_id.clone()); + let resource_asset_kind = normalize_optional(input.project_resource.asset_kind.clone()); + let asset_asset_kind = normalize_optional(input.asset.asset_kind.clone()); + let expected_task_id = format!("pixel-art-snap-{operation_id}"); + let expected_asset_object_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset-object", + "assetobj_", + ); + let expected_resource_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "project-resource", + "editor-resource-", + ); + let expected_asset_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset", + "editor-asset-", + ); + + if operation_id != dialog_id + || operation_id != input.operation_id.trim() + || dialog_id != input.canvas_completion.dialog_id.trim() + { + return Err("完美像素 operation_id 必须等于规范化 dialog_id".to_string()); + } + if operation_id.contains('\0') || dialog_id.contains('\0') { + return Err("完美像素 operation_id 与 dialog_id 不得包含 NUL".to_string()); + } + if operation_fingerprint.len() != 64 + || !operation_fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string()); + } + if task_id != expected_task_id + || asset_object_id != expected_asset_object_id + || resource_id != expected_resource_id + || asset_id != expected_asset_id + { + return Err("完美像素 task/object/resource/asset 未使用稳定 operation 身份".to_string()); + } + if input.asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) + || input.asset_object.profile_id.is_some() + || input.asset_object.access_policy != AssetObjectAccessPolicy::Private + || input.asset_object.asset_kind.trim() != "editor_pixel_art_snap" + || input.asset_object.content_length == 0 + || !input + .asset_object + .content_type + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("image/png")) + { + return Err("完美像素 asset_object 的 owner、访问策略或媒体类型不合法".to_string()); + } + if !object_key.contains(operation_fingerprint) { + return Err("完美像素 object_key 未包含 operation_fingerprint".to_string()); + } + if input.asset_object.entity_id.as_deref().map(str::trim) != Some(task_id.as_str()) { + return Err("完美像素 asset_object.entity_id 必须等于稳定 task_id".to_string()); + } + if input.project_resource.project_id.trim() != project_id + || input.project_resource.owner_user_id.trim() != owner_user_id + || normalize_optional(input.project_resource.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.project_resource.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.project_resource.image_src.as_str()) != object_key + || input.project_resource.source_type.trim() != "generated" + || resource_task_id.as_deref() != Some(task_id.as_str()) + { + return Err("完美像素项目资源与对象的 owner、媒体或任务归属不一致".to_string()); + } + if input.asset.owner_user_id.trim() != owner_user_id + || normalize_optional(input.asset.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.asset.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.asset.image_src.as_str()) != object_key + || input.asset.source_type.trim() != "generated" + || asset_task_id.as_deref() != Some(task_id.as_str()) + || normalize_optional(input.asset.source_resource_id.clone()).as_deref() + != Some(resource_id.as_str()) + { + return Err("完美像素账号素材与对象、项目资源的 owner、媒体或任务归属不一致".to_string()); + } + if resource_asset_kind != asset_asset_kind + || normalize_optional(input.project_resource.prompt.clone()) + != normalize_optional(input.asset.prompt.clone()) + || normalize_optional(input.project_resource.actual_prompt.clone()) + != normalize_optional(input.asset.actual_prompt.clone()) + || normalize_optional(input.project_resource.model.clone()) + != normalize_optional(input.asset.model.clone()) + || normalize_optional(input.project_resource.provider.clone()) + != normalize_optional(input.asset.provider.clone()) + || normalize_optional(input.project_resource.generation_inputs_json.clone()) + != normalize_optional(input.asset.generation_inputs_json.clone()) + || input.project_resource.width != input.asset.width + || input.project_resource.height != input.asset.height + || input.asset.generation_cost_mud_points != 0 + || input.asset.group_task_id.is_some() + || input.asset.group_task_expected_asset_count.is_some() + { + return Err("完美像素项目资源与账号素材的内容不一致".to_string()); + } + if input.asset_object.updated_at_micros != input.completed_at_micros + || input.project_resource.updated_at_micros != input.completed_at_micros + || input.asset.now_micros != input.completed_at_micros + { + return Err("完美像素持久化时间必须使用同一 completed_at_micros".to_string()); + } + normalize_required(&input.canvas_completion.title, "canvas_completion.title")?; + validate_editor_pixel_art_placeholder(&input.canvas_completion.placeholder)?; + normalize_required(&input.asset.folder_id, "asset.folder_id")?; + normalize_required(&asset_id, "asset.asset_id")?; + + if let Some(source_resource_id) = + normalize_optional(input.project_resource.source_resource_id.clone()) + { + let source_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id) + .ok_or_else(|| "完美像素来源项目资源不存在".to_string())?; + if source_resource.resource_id == resource_id + || source_resource.owner_user_id != owner_user_id + || source_resource.project_id != project_id + { + return Err("完美像素来源资源不属于同一 owner 与项目".to_string()); + } + } + + Ok(()) +} + +fn editor_pixel_art_stable_record_id( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, + prefix: &str, +) -> String { + let digest = Sha256::digest( + format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ) + .as_bytes(), + ); + let suffix = digest + .iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("{prefix}{suffix}") +} + +fn editor_pixel_art_idempotency_conflict(reason: impl AsRef) -> String { + format!("完美像素幂等冲突:{}", reason.as_ref()) +} + +fn validate_editor_pixel_art_placeholder( + placeholder: &EditorPixelArtCanvasPlaceholderInput, +) -> Result<(), String> { + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err("完美像素 canvas placeholder 坐标或尺寸不合法".to_string()); + } + Ok(()) +} + +fn load_editor_pixel_art_existing_records( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, +) -> Result { + let candidate = &input.asset_object; + let by_id = crate::asset_metadata::find_asset_object_snapshot_by_id( + ctx, + candidate.asset_object_id.as_str(), + ); + let by_location = crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: candidate.bucket.clone(), + object_key: candidate.object_key.clone(), + }, + ) + .map_err(|message| { + if message.contains("重复记录") { + editor_pixel_art_idempotency_conflict(message) + } else { + message + } + })?; + if let (Some(by_id), Some(by_location)) = (&by_id, &by_location) + && by_id.asset_object_id != by_location.asset_object_id + { + return Err(editor_pixel_art_idempotency_conflict( + "对象位置已由其他 asset_object_id 占用", + )); + } + let asset_object = by_id.or(by_location); + if let Some(existing) = &asset_object + && !editor_pixel_art_asset_object_matches(existing, candidate) + { + return Err("完美像素 asset_object 幂等键已被其他内容占用".to_string()); + } + + let resource_id = input.project_resource.resource_id.trim().to_string(); + let project_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id); + if let Some(existing) = &project_resource + && !editor_pixel_art_project_resource_matches(existing, &input.project_resource) + { + return Err("完美像素 project_resource 幂等键已被其他内容占用".to_string()); + } + let asset_id = input.asset.asset_id.trim().to_string(); + let asset = ctx.db.editor_asset().asset_id().find(&asset_id); + if let Some(existing) = &asset + && !editor_pixel_art_asset_matches(existing, &input.asset) + { + return Err("完美像素 editor_asset 幂等键已被其他内容占用".to_string()); + } + + match ( + asset_object, + project_resource.map(resource_snapshot_from_row), + asset.map(|row| asset_snapshot_from_row(ctx, row)), + ) { + (None, None, None) => Ok(EditorPixelArtExistingRecords::New), + (Some(asset_object), Some(project_resource), Some(asset)) => { + Ok(EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + }) + } + _ => Err("完美像素幂等记录不完整,拒绝补写部分状态".to_string()), + } +} + +fn editor_pixel_art_asset_object_matches( + existing: &AssetObjectUpsertSnapshot, + input: &AssetObjectUpsertInput, +) -> bool { + existing.asset_object_id.trim() == input.asset_object_id.trim() + && existing.bucket.trim() == input.bucket.trim() + && existing.object_key.trim() == input.object_key.trim() + && existing.access_policy == input.access_policy + && normalize_optional(existing.content_type.clone()) + == normalize_optional(input.content_type.clone()) + && existing.content_length == input.content_length + && normalize_optional(existing.content_hash.clone()) + == normalize_optional(input.content_hash.clone()) + && existing.version == input.version + && normalize_optional(existing.source_job_id.clone()) + == normalize_optional(input.source_job_id.clone()) + && normalize_optional(existing.owner_user_id.clone()) + == normalize_optional(input.owner_user_id.clone()) + && normalize_optional(existing.profile_id.clone()) + == normalize_optional(input.profile_id.clone()) + && normalize_optional(existing.entity_id.clone()) + == normalize_optional(input.entity_id.clone()) + && existing.asset_kind.trim() == input.asset_kind.trim() +} + +fn editor_pixel_art_project_resource_matches( + existing: &EditorProjectResource, + input: &EditorProjectResourceCreateInput, +) -> bool { + existing.resource_id.trim() == input.resource_id.trim() + && existing.project_id.trim() == input.project_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && !existing.public_showcase_enabled +} + +fn editor_pixel_art_asset_matches(existing: &EditorAsset, input: &EditorAssetCreateInput) -> bool { + existing.asset_id.trim() == input.asset_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.folder_id.trim() == input.folder_id.trim() + && existing.label == normalize_asset_label(&input.label) + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.thumbnail_src == normalize_optional(input.thumbnail_src.clone()) + && existing.generation_cost_mud_points == input.generation_cost_mud_points + && existing.group_task_id == normalize_optional(input.group_task_id.clone()) + && existing.group_task_expected_asset_count == input.group_task_expected_asset_count +} + +fn resolve_editor_pixel_art_replay_project_snapshot( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result, String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(None); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json + }; + if !validate_editor_pixel_art_canvas_replay(layers_json.as_str(), completion, resource)? { + return Ok(None); + } + build_project_snapshot(ctx, project_id).map(Some) +} + +fn validate_editor_pixel_art_canvas_replay( + layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + match plan_editor_pixel_art_canvas_layout(layers_json, completion, resource)? { + EditorPixelArtCanvasLayoutPlan::DialogMissing => Ok(false), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => Ok(true), + EditorPixelArtCanvasLayoutPlan::Apply(_) => Err( + "稳定 object/resource/asset 已存在,但画布 dialog 尚未由该 operation 收口".to_string(), + ), + } +} + +fn plan_editor_pixel_art_canvas_completion( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let current_layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json.clone() + }; + match plan_editor_pixel_art_canvas_layout(current_layers_json.as_str(), completion, resource)? { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => { + Ok(EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + }) + } + EditorPixelArtCanvasLayoutPlan::DialogMissing => { + Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing) + } + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => { + Ok(EditorPixelArtCanvasCompletionPlan::AlreadyApplied) + } + } +} + +fn plan_editor_pixel_art_canvas_layout( + current_layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + let mut layout = serde_json::from_str::(current_layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = layout + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let dialog_id = completion.dialog_id.trim(); + let dialog_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let dialog = canvas_generation_dialog(item)?; + (canvas_generation_dialog_id(dialog).as_deref() == Some(dialog_id)).then_some(index) + }) + .collect::>(); + if dialog_indexes.is_empty() { + return Ok(EditorPixelArtCanvasLayoutPlan::DialogMissing); + } + if dialog_indexes.len() != 1 { + return Err("完美像素画布包含重复 dialog_id".to_string()); + } + let dialog_index = dialog_indexes[0]; + let dialog = canvas_generation_dialog(&items[dialog_index]) + .cloned() + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + let generated_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + let matching_layer_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + (canvas_layout_layer_id(item).as_deref() == Some(generated_layer_id.as_str())) + .then_some(index) + }) + .collect::>(); + if matching_layer_indexes.len() > 1 { + return Err("完美像素画布包含重复结果图层".to_string()); + } + + let dialog_status = canvas_generation_dialog_status(&dialog); + let completed_layer_id = canvas_generation_dialog_generated_layer_id(&dialog); + if dialog_status == Some("generating") && completed_layer_id.is_some() { + return Err("完美像素旧结果不得覆盖同一 dialog 的在途重试".to_string()); + } + if let Some(completed_layer_id) = completed_layer_id { + if completed_layer_id != generated_layer_id { + return Err("完美像素 dialog 已由其他结果完成".to_string()); + } + let existing_layer = matching_layer_indexes + .first() + .and_then(|index| items.get(*index)) + .ok_or_else(|| "完美像素 dialog 指向的结果图层不存在".to_string())?; + if !editor_pixel_art_canvas_layer_matches(existing_layer, resource) { + return Err("完美像素已完成图层与持久化资源不一致".to_string()); + } + return Ok(EditorPixelArtCanvasLayoutPlan::AlreadyApplied); + } + if !matching_layer_indexes.is_empty() { + return Err("完美像素稳定 layer_id 已被未关联图层占用".to_string()); + } + + let placeholder = editor_pixel_art_dialog_placeholder(&dialog) + .filter(|placeholder| validate_editor_pixel_art_placeholder(placeholder).is_ok()) + .unwrap_or_else(|| completion.placeholder.clone()); + let next_z_index = items + .iter() + .filter_map(|item| item.get("zIndex").and_then(JsonValue::as_i64)) + .max() + .unwrap_or(0) + + 1; + let result_layer = build_editor_pixel_art_canvas_layer( + completion, + &placeholder, + resource, + generated_layer_id.as_str(), + next_z_index, + )?; + items.push(result_layer); + let dialog_object = items + .get_mut(dialog_index) + .and_then(|item| item.get_mut("dialog")) + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + dialog_object.insert("status".to_string(), JsonValue::String("idle".to_string())); + dialog_object.insert("composerOpen".to_string(), JsonValue::Bool(false)); + dialog_object.insert( + "generatedLayerId".to_string(), + JsonValue::String(generated_layer_id), + ); + dialog_object.remove("errorMessage"); + + serde_json::to_string(&layout) + .map(EditorPixelArtCanvasLayoutPlan::Apply) + .map_err(|_| "完美像素完成后的画布布局无法序列化".to_string()) +} + +fn editor_pixel_art_dialog_placeholder( + dialog: &JsonValue, +) -> Option { + let placeholder = dialog.get("placeholder")?.as_object()?; + Some(EditorPixelArtCanvasPlaceholderInput { + x: json_optional_number(placeholder.get("x"))?, + y: json_optional_number(placeholder.get("y"))?, + width: json_optional_number(placeholder.get("width"))?, + height: json_optional_number(placeholder.get("height"))?, + original_width: json_optional_number(placeholder.get("originalWidth"))?, + original_height: json_optional_number(placeholder.get("originalHeight"))?, + }) +} + +fn build_editor_pixel_art_canvas_layer( + completion: &EditorPixelArtCanvasCompletionInput, + placeholder: &EditorPixelArtCanvasPlaceholderInput, + resource: &EditorProjectResourceSnapshot, + generated_layer_id: &str, + z_index: i64, +) -> Result { + let original_width = + positive_editor_pixel_art_dimension(f64::from(resource.width), placeholder.original_width); + let original_height = positive_editor_pixel_art_dimension( + f64::from(resource.height), + placeholder.original_height, + ); + let width = positive_editor_pixel_art_dimension(original_width, placeholder.width); + let height = positive_editor_pixel_art_dimension(original_height, placeholder.height); + let placeholder_width = positive_editor_pixel_art_dimension(placeholder.width, width); + let placeholder_height = positive_editor_pixel_art_dimension(placeholder.height, height); + let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; + let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; + let generation_inputs = resource + .generation_inputs_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|_| "完美像素项目资源 generation_inputs_json 不是合法 JSON".to_string())? + .unwrap_or(JsonValue::Null); + + Ok(serde_json::json!({ + "layerId": generated_layer_id, + "resourceId": resource.resource_id, + "title": completion.title.trim(), + "src": resource.image_src, + "x": x, + "y": y, + "width": width, + "height": height, + "originalWidth": original_width, + "originalHeight": original_height, + "zIndex": z_index, + "sourceType": "generated", + "mediaType": "image", + "prompt": resource.prompt, + "actualPrompt": resource.actual_prompt, + "model": resource.model, + "provider": resource.provider, + "taskId": resource.task_id, + "objectKey": resource.object_key, + "assetObjectId": resource.asset_object_id, + "sourceResourceId": resource.source_resource_id, + "assetKind": resource.asset_kind, + "generationInputs": generation_inputs, + })) +} + +fn editor_pixel_art_canvas_layer_matches( + item: &JsonValue, + resource: &EditorProjectResourceSnapshot, +) -> bool { + let expected_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + item.get("layerId").and_then(JsonValue::as_str) == Some(expected_layer_id.as_str()) + && item.get("resourceId").and_then(JsonValue::as_str) == Some(resource.resource_id.as_str()) + && item.get("objectKey").and_then(JsonValue::as_str) == resource.object_key.as_deref() + && item.get("assetObjectId").and_then(JsonValue::as_str) + == resource.asset_object_id.as_deref() + && item.get("taskId").and_then(JsonValue::as_str) == resource.task_id.as_deref() +} + +fn positive_editor_pixel_art_dimension(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value.round().max(1.0) + } else if fallback.is_finite() && fallback > 0.0 { + fallback.round().max(1.0) + } else { + 1.0 + } +} + fn create_or_reuse_editor_spritesheet_asset( ctx: &ReducerContext, caller: Identity, @@ -8453,6 +9317,215 @@ mod tests { } } + fn pixel_art_project_resource() -> EditorProjectResourceSnapshot { + EditorProjectResourceSnapshot { + resource_id: "editor-resource-pixel".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "owner-1".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/generated/editor/pixel/result.png".to_string(), + object_key: Some("generated/editor/pixel/result.png".to_string()), + width: 32, + height: 16, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some("Perfect Pixel".to_string()), + provider: Some("Genarrative".to_string()), + 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("{\"source\":\"canvas\"}".to_string()), + public_showcase_enabled: false, + created_at_micros: 1_000_000, + updated_at_micros: 1_000_000, + } + } + + fn pixel_art_canvas_completion() -> EditorPixelArtCanvasCompletionInput { + EditorPixelArtCanvasCompletionInput { + dialog_id: "dialog-1".to_string(), + title: "结果".to_string(), + placeholder: EditorPixelArtCanvasPlaceholderInput { + x: 100.0, + y: 50.0, + width: 200.0, + height: 100.0, + original_width: 200.0, + original_height: 100.0, + }, + } + } + + #[test] + fn pixel_art_stable_record_ids_are_domain_separated() { + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset-object", + "assetobj_", + ), + "assetobj_8a264e1086ee3d6878d753aec254e0a5" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "project-resource", + "editor-resource-", + ), + "editor-resource-9eee84b77e8828ca8f5042919198ac1c" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset", + "editor-asset-", + ), + "editor-asset-c7e7e66538f4613226e68001b8408104" + ); + } + + #[test] + fn pixel_art_canvas_completion_applies_once_and_replays_without_mutation() { + let layout = json!([ + { + "layerId": "layer-source", + "resourceId": "source-resource", + "zIndex": 4 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "composerOpen": false, + "errorMessage": "旧错误", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let completion = pixel_art_canvas_completion(); + let resource = pixel_art_project_resource(); + let layout_json = layout.to_string(); + let pending_replay_error = + validate_editor_pixel_art_canvas_replay(layout_json.as_str(), &completion, &resource) + .expect_err("stable records must not complete an unresolved dialog during replay"); + assert!(pending_replay_error.contains("尚未由该 operation 收口")); + let applied = + match plan_editor_pixel_art_canvas_layout(layout_json.as_str(), &completion, &resource) + .expect("pixel-art completion should apply") + { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json, + _ => panic!("first completion must apply"), + }; + let applied_value: JsonValue = + serde_json::from_str(applied.as_str()).expect("applied layout"); + let items = applied_value.as_array().expect("layout array"); + let layer = items + .iter() + .find(|item| item["layerId"] == "layer-editor-resource-editor-resource-pixel") + .expect("generated layer"); + assert_eq!(layer["resourceId"], json!("editor-resource-pixel")); + assert_eq!(layer["x"], json!(184.0)); + assert_eq!(layer["y"], json!(92.0)); + assert_eq!(layer["zIndex"], json!(5)); + assert_eq!(layer["generationInputs"]["source"], json!("canvas")); + let dialog = items + .iter() + .find_map(canvas_generation_dialog) + .expect("generation dialog"); + assert_eq!(dialog["status"], json!("idle")); + assert_eq!(dialog["composerOpen"], json!(false)); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-editor-resource-pixel") + ); + assert!(dialog.get("errorMessage").is_none()); + assert!( + validate_editor_pixel_art_canvas_replay(applied.as_str(), &completion, &resource) + .expect("applied replay") + ); + assert!(matches!( + plan_editor_pixel_art_canvas_layout(applied.as_str(), &completion, &resource) + .expect("exact replay"), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied + )); + } + + #[test] + fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() { + let layout = json!([{ + "layerId": "layer-source", + "resourceId": "source-resource" + }]) + .to_string(); + assert!( + !validate_editor_pixel_art_canvas_replay( + layout.as_str(), + &pixel_art_canvas_completion(), + &pixel_art_project_resource(), + ) + .expect("missing dialog is a committed asset-only replay outcome") + ); + } + + #[test] + fn pixel_art_late_completion_does_not_overwrite_active_retry() { + let resource = pixel_art_project_resource(); + let completion = pixel_art_canvas_completion(); + let layout = json!([ + { + "layerId": "layer-editor-resource-editor-resource-pixel", + "resourceId": "editor-resource-pixel", + "objectKey": "generated/editor/pixel/result.png", + "assetObjectId": "assetobj_pixel", + "taskId": "pixel-art-snap-dialog-1" + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "generatedLayerId": "layer-editor-resource-editor-resource-pixel", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let error = validate_editor_pixel_art_canvas_replay( + layout.to_string().as_str(), + &completion, + &resource, + ) + .expect_err("late completion must not overwrite an active retry"); + assert!(error.contains("在途重试")); + } + #[test] fn spritesheet_slice_batch_validation_accepts_complete_owned_cohort() { let batch = spritesheet_slice_batch(); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index b55ee9053..248545a62 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { EditorProjectLayerSnapshot, @@ -18,6 +18,7 @@ import { INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, normalizeAssetLibrary, normalizeCanvasBackgroundHex, + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, resolveNextInlineGenerationDialogExpiryAt, resolveSnappedLayerPosition, serializeCanvasLayout, @@ -30,9 +31,54 @@ import type { CanvasGenerationDialogState, CanvasLayer, EditorAsset, + PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; import { PERFECT_PIXEL_PRE_POST_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; +function buildPerfectPixelOperation( + dialogId: string, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '提示词', value: '像素角色' }], + references: [ + { + title: '源图', + label: '角色原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, + assetFolderId: 'project', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_240_000, + }; +} + describe('ImageCanvasEditorModel', () => { it('maps viewport scale to the user-facing canvas zoom scale', () => { expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25); @@ -48,9 +94,9 @@ describe('ImageCanvasEditorModel', () => { expect( viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }), ).toEqual({ x: 10, y: 20, scale: 0.5 }); - expect( - canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }), - ).toEqual({ x: 10, y: 20, scale: 1 }); + expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual( + { x: 10, y: 20, scale: 1 }, + ); }); it('normalizes valid canvas background hex values and rejects invalid input', () => { @@ -92,7 +138,9 @@ describe('ImageCanvasEditorModel', () => { }); expect(layout).toEqual([]); - expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined(); + expect( + splitCanvasLayoutItems(layout).canvasBackgroundColor, + ).toBeUndefined(); expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc'); }); @@ -137,7 +185,8 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-audio', folderId: 'project', label: '胜利音效.mp3', - imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', + imageSrc: + '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', width: 420, height: 120, sourceType: 'uploaded', @@ -148,7 +197,8 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-video', folderId: 'project', label: '开场动画.mp4', - imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', + imageSrc: + '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', thumbnailSrc: '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', width: 560, @@ -261,7 +311,8 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-generated', folderId: 'project', label: '生成素材', - imageSrc: '/generated-character-drafts/editor/asset-library/generated.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/generated.png', width: 640, height: 640, sourceType: 'generated', @@ -400,8 +451,10 @@ describe('ImageCanvasEditorModel', () => { [ 'resource-uploaded', { - imageSrc: '/generated-character-drafts/editor/asset-library/image.png', - objectKey: 'generated-character-drafts/editor/asset-library/image.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/image.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image.png', assetObjectId: 'asset-object-uploaded', sourceResourceId: 'resource-source', }, @@ -434,7 +487,9 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', assetKind: 'publication-material', }, - new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]), + new Map([ + ['resource-publication', { imageSrc: '/read/publication.png' }], + ]), ); expect(hydrated).toMatchObject({ @@ -717,12 +772,16 @@ describe('ImageCanvasEditorModel', () => { }); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).not.toContain('data:image'); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).toContain('ref:project-resource:resource-reference-1'); @@ -765,6 +824,174 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('strictly round-trips a pending perfect-pixel operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-round-trip'; + const operation = buildPerfectPixelOperation(dialogId); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + requiresLiveSession: true, + perfectPixelOperation: operation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: operation, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual(operation); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('fails closed instead of replaying an invalid perfect-pixel operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-invalid'; + const operation = buildPerfectPixelOperation(dialogId); + const invalidOperations: unknown[] = [ + { ...operation, version: 2 }, + { ...operation, operationId: 'another-dialog' }, + { ...operation, taskId: 'pixel-art-snap-another-dialog' }, + { + ...operation, + request: { ...operation.request, projectId: ' ' }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: 'data:image/png;base64,unsafe', + }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: + 'https://oss.example.test/source.png?Expires=1700000000&Signature=temporary', + }, + }, + { + ...operation, + request: { + ...operation.request, + canvasCompletion: { + ...operation.request.canvasCompletion, + dialogId: 'another-dialog', + }, + }, + }, + { + ...operation, + reconcileUntil: + operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 1, + }, + { ...operation, unknownFutureField: true }, + ]; + + for (const invalidOperation of invalidOperations) { + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: invalidOperation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + } + }); + + it('caps a clock-skewed perfect-pixel recovery to one current observation window', () => { + vi.useFakeTimers(); + const now = 1_700_000_000_000; + vi.setSystemTime(now); + try { + const dialogId = 'dialog-perfect-pixel-clock-skew'; + const operation = buildPerfectPixelOperation(dialogId); + const submittedAt = now + 60_000; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: { + ...operation, + submittedAt, + reconcileUntil: submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }, + }); + + expect(hydrated?.perfectPixelOperation).toMatchObject({ + submittedAt, + reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('round-trips the blocked marker after clearing an invalid operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-blocked'; + const operation = buildPerfectPixelOperation(dialogId); + const failedClosed = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperation: { ...operation, taskId: 'wrong-task' }, + }); + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [failedClosed as CanvasGenerationDialogState], + }), + ); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('fails closed when pending-confirmation has no operation journal', () => { + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-perfect-pixel-orphaned-pending', + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + }); + + expect(hydrated).toMatchObject({ + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + }); + it('defaults restored supported image styles to none and drops them from other modes', () => { expect( hydrateCanvasGenerationDialog({ @@ -1038,8 +1265,18 @@ describe('ImageCanvasEditorModel', () => { // 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界 // 必须一致——正好到期不算过期,超过一毫秒才算。 const now = 1_700_000_000_000; - const live = [dialog({ requiresLiveSession: true, generationStartedAt: now - 240_000 })]; - const stale = [dialog({ requiresLiveSession: true, generationStartedAt: now - 240_001 })]; + const live = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_000, + }), + ]; + const stale = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }), + ]; expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]); expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([ @@ -1065,6 +1302,26 @@ describe('ImageCanvasEditorModel', () => { expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull(); }); + it('never expires a generating placeholder backed by a perfect-pixel operation snapshot', () => { + const operationDialogId = 'operation-backed'; + const operationBackedDialog = dialog({ + id: operationDialogId, + requiresLiveSession: true, + generationStartedAt: 1, + perfectPixelOperation: buildPerfectPixelOperation(operationDialogId), + }); + + expect( + collectExpiredInlineGenerationDialogIds( + [operationBackedDialog], + 1_700_000_000_000, + ), + ).toEqual([]); + expect( + resolveNextInlineGenerationDialogExpiryAt([operationBackedDialog]), + ).toBeNull(); + }); + it('expires a live-session placeholder that carries no usable timestamp', () => { // 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。 expect( @@ -1079,8 +1336,16 @@ describe('ImageCanvasEditorModel', () => { // 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。 const now = 1_700_000_000_000; const dialogs = [ - dialog({ id: 'later', requiresLiveSession: true, generationStartedAt: now - 10_000 }), - dialog({ id: 'sooner', requiresLiveSession: true, generationStartedAt: now - 60_000 }), + dialog({ + id: 'later', + requiresLiveSession: true, + generationStartedAt: now - 10_000, + }), + dialog({ + id: 'sooner', + requiresLiveSession: true, + generationStartedAt: now - 60_000, + }), ]; expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe( @@ -1101,6 +1366,21 @@ describe('ImageCanvasEditorModel', () => { updatedAt: '2026-08-03T00:00:00.000Z', }); + const withCanvasMirror = ( + project: EditorProjectSnapshot, + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + ...project, + canvas: { + canvasId: 'canvas-1', + projectId: project.projectId, + title: project.title, + viewport: project.viewport, + layers, + updatedAt: project.updatedAt, + }, + }); + const buildDialogItem = ( id: string, dialog: Record, @@ -1157,9 +1437,9 @@ describe('ImageCanvasEditorModel', () => { }), ]); - expect(dropDeadInlineGenerationPlaceholders(project, now).droppedCount).toBe( - 0, - ); + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(0); }); it('drops a live-session placeholder once the window has elapsed', () => { @@ -1180,6 +1460,22 @@ describe('ImageCanvasEditorModel', () => { expect(result.project.layers).toEqual([]); }); + it('drops a dead legacy placeholder from both layout mirrors exactly once', () => { + const now = 1_700_000_000_000; + const stale = buildDialogItem('stale-mirrored', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }); + const project = withCanvasMirror(buildProject([stale]), [stale]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + expect(result.project.canvas?.layers).toEqual([]); + }); + it('drops a live-session placeholder that carries no usable timestamp', () => { // 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏 // 只是退回引入时间窗之前的行为。 @@ -1193,7 +1489,9 @@ describe('ImageCanvasEditorModel', () => { buildDialogItem('no-timestamp', { status: 'generating', requiresLiveSession: true, - ...(generationStartedAt === undefined ? {} : { generationStartedAt }), + ...(generationStartedAt === undefined + ? {} + : { generationStartedAt }), }), ]); @@ -1220,6 +1518,56 @@ describe('ImageCanvasEditorModel', () => { expect(result.project).toBe(project); }); + it('keeps generating and pending placeholders backed by a perfect-pixel operation snapshot', () => { + const now = 1_700_000_000_000; + for (const status of ['generating', 'pending-confirmation'] as const) { + const dialogId = `operation-${status}`; + const operationBacked = buildDialogItem(dialogId, { + status, + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + }); + const project = withCanvasMirror(buildProject([operationBacked]), [ + operationBacked, + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + } + }); + + it('preserves an invalid operation journal long enough for hydration to fail closed', () => { + const now = 1_700_000_000_000; + const invalidJournal = buildDialogItem('invalid-operation', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: { + ...buildPerfectPixelOperation('invalid-operation'), + taskId: 'wrong-task', + }, + }); + const project = buildProject([invalidJournal]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + const { generationDialogs } = splitCanvasLayoutItems( + result.project.layers, + ); + + expect(result.droppedCount).toBe(0); + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: 'invalid-operation', + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + it('keeps settled inline placeholders and unrelated layout items', () => { const settled = buildDialogItem('settled', { status: 'failed', @@ -1257,8 +1605,8 @@ describe('ImageCanvasEditorModel', () => { }); expect( - (item as unknown as { dialog: { requiresLiveSession?: boolean } }).dialog - .requiresLiveSession, + (item as unknown as { dialog: { requiresLiveSession?: boolean } }) + .dialog.requiresLiveSession, ).toBe(true); }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index f9ca144f8..1f4591c56 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1,6 +1,8 @@ import type { + EditorAssetGenerationInputs, EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, + EditorPixelArtSnapInput, EditorProjectLayerSnapshot, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; @@ -16,6 +18,7 @@ import type { CharacterReferenceImage, EditorAsset, EditorAssetFolder, + PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; @@ -259,7 +262,10 @@ function serializeImageSequenceFrames( return undefined; } const serializedFrames = frames.flatMap((frame) => { - const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey); + const imageSrc = serializeOptionalMediaSource( + frame.imageSrc, + frame.objectKey, + ); return imageSrc ? [ { @@ -272,9 +278,7 @@ function serializeImageSequenceFrames( return serializedFrames.length ? serializedFrames : undefined; } -export function serializeLayer( - layer: CanvasLayer, -): EditorProjectLayerSnapshot { +export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { return { layerId: layer.id, resourceId: layer.resourceId, @@ -292,7 +296,9 @@ export function serializeLayer( layer.thumbnailSrc, layer.objectKey, ), - imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames), + imageSequenceFrames: serializeImageSequenceFrames( + layer.imageSequenceFrames, + ), previewVideoPath: serializeOptionalMediaSource( layer.previewVideoPath, layer.objectKey, @@ -328,6 +334,284 @@ type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & { export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; +const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-'; +// 中文注释:从稳定请求快照写入开始计算整条提交/对账窗口。绝对截止时间会持久化并在 +// hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在“从当前最多再观察一个窗口”。 +export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000; +export const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE = + '完美像素操作快照无效,禁止自动重试。'; + +const PERFECT_PIXEL_OPERATION_KEYS = new Set([ + 'version', + 'kind', + 'operationId', + 'taskId', + 'request', + 'submittedAt', + 'reconcileUntil', +]); +const PERFECT_PIXEL_REQUEST_KEYS = new Set([ + 'sourceImageSrc', + 'projectId', + 'sourceResourceId', + 'assetKind', + 'generationInputs', + 'assetFolderId', + 'assetLabel', + 'canvasCompletion', +]); +const PERFECT_PIXEL_COMPLETION_KEYS = new Set([ + 'dialogId', + 'title', + 'placeholder', +]); +const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([ + 'x', + 'y', + 'width', + 'height', + 'originalWidth', + 'originalHeight', +]); +const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']); +const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']); +const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([ + 'title', + 'label', + 'refType', + 'refId', +]); + +function isSnapshotRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasOnlySnapshotKeys( + value: Record, + allowedKeys: ReadonlySet, +) { + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +function isOptionalNullableString(value: unknown) { + return value === undefined || value === null || typeof value === 'string'; +} + +function isStableEditorMediaReference(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + const normalized = value.trimStart().toLowerCase(); + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { + return false; + } + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return true; + } + const signedQueryKeys = new Set([ + 'expires', + 'signature', + 'x-amz-signature', + 'x-amz-security-token', + 'x-oss-signature', + 'x-oss-security-token', + ]); + return [...url.searchParams.keys()].every( + (key) => !signedQueryKeys.has(key.toLowerCase()), + ); + } catch { + return true; + } +} + +function hydratePerfectPixelGenerationInputs( + value: unknown, +): EditorAssetGenerationInputs | null { + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) || + !Array.isArray(value.fields) || + !Array.isArray(value.references) + ) { + return null; + } + const fields = value.fields.flatMap((field) => { + if ( + !isSnapshotRecord(field) || + !hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) || + typeof field.title !== 'string' || + typeof field.value !== 'string' + ) { + return []; + } + return [{ title: field.title, value: field.value }]; + }); + const references: EditorAssetGenerationInputs['references'] = + value.references.flatMap((reference) => { + if ( + !isSnapshotRecord(reference) || + !hasOnlySnapshotKeys( + reference, + PERFECT_PIXEL_GENERATION_REFERENCE_KEYS, + ) || + typeof reference.title !== 'string' || + typeof reference.label !== 'string' || + (reference.refType !== 'project-resource' && + reference.refType !== 'asset') || + typeof reference.refId !== 'string' + ) { + return []; + } + return [ + { + title: reference.title, + label: reference.label, + refType: reference.refType as 'project-resource' | 'asset', + refId: reference.refId, + }, + ]; + }); + if ( + fields.length !== value.fields.length || + references.length !== value.references.length + ) { + return null; + } + return { fields, references }; +} + +/** + * 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。 + * 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份; + * 任何未知版本、字段漂移或不稳定媒体引用都失败关闭,绝不能据当前画布状态猜测并重放 POST。 + */ +export function hydratePerfectPixelOperation( + value: unknown, + dialogId: string, +): PerfectPixelOperationSnapshot | null { + const now = Date.now(); + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_OPERATION_KEYS) || + value.version !== 1 || + value.kind !== 'perfect-pixel' || + value.operationId !== dialogId || + value.taskId !== `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}` || + typeof value.submittedAt !== 'number' || + !Number.isFinite(value.submittedAt) || + value.submittedAt <= 0 || + typeof value.reconcileUntil !== 'number' || + !Number.isFinite(value.reconcileUntil) || + value.reconcileUntil < value.submittedAt || + value.reconcileUntil - value.submittedAt > + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS || + value.submittedAt > now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + ) { + return null; + } + const request = value.request; + if ( + !isSnapshotRecord(request) || + !hasOnlySnapshotKeys(request, PERFECT_PIXEL_REQUEST_KEYS) || + !isStableEditorMediaReference(request.sourceImageSrc) || + typeof request.projectId !== 'string' || + !request.projectId.trim() || + !isOptionalNullableString(request.sourceResourceId) || + !isOptionalNullableString(request.assetKind) || + !isOptionalNullableString(request.assetFolderId) || + !isOptionalNullableString(request.assetLabel) + ) { + return null; + } + const completion = request.canvasCompletion; + if ( + !isSnapshotRecord(completion) || + !hasOnlySnapshotKeys(completion, PERFECT_PIXEL_COMPLETION_KEYS) || + completion.dialogId !== dialogId || + typeof completion.title !== 'string' + ) { + return null; + } + const placeholder = completion.placeholder; + if ( + !isSnapshotRecord(placeholder) || + !hasOnlySnapshotKeys(placeholder, PERFECT_PIXEL_PLACEHOLDER_KEYS) || + ![...PERFECT_PIXEL_PLACEHOLDER_KEYS].every( + (key) => + typeof placeholder[key] === 'number' && + Number.isFinite(placeholder[key]), + ) + ) { + return null; + } + let generationInputs: EditorAssetGenerationInputs | null | undefined; + if (request.generationInputs === undefined) { + generationInputs = undefined; + } else if (request.generationInputs === null) { + generationInputs = null; + } else { + generationInputs = hydratePerfectPixelGenerationInputs( + request.generationInputs, + ); + } + if ( + request.generationInputs !== undefined && + request.generationInputs !== null && + !generationInputs + ) { + return null; + } + + const hydratedRequest: EditorPixelArtSnapInput = { + sourceImageSrc: request.sourceImageSrc, + projectId: request.projectId, + ...(request.sourceResourceId !== undefined + ? { + sourceResourceId: request.sourceResourceId as string | null, + } + : {}), + ...(request.assetKind !== undefined + ? { assetKind: request.assetKind as string | null } + : {}), + ...(generationInputs !== undefined ? { generationInputs } : {}), + ...(request.assetFolderId !== undefined + ? { assetFolderId: request.assetFolderId as string | null } + : {}), + ...(request.assetLabel !== undefined + ? { assetLabel: request.assetLabel as string | null } + : {}), + canvasCompletion: { + dialogId, + title: completion.title, + placeholder: { + x: placeholder.x as number, + y: placeholder.y as number, + width: placeholder.width as number, + height: placeholder.height as number, + originalWidth: placeholder.originalWidth as number, + originalHeight: placeholder.originalHeight as number, + }, + }, + }; + + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`, + request: hydratedRequest, + submittedAt: value.submittedAt, + // 中文注释:跨设备时钟可能有小幅偏差,所以合法固定跨度的快照不因“当前时钟稍慢” + // 直接失效;但恢复从当前时刻起最多再观察一个完整窗口,不能信任远未来绝对时间让 GET + // 轮询持续数小时。 + reconcileUntil: Math.min( + value.reconcileUntil, + now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + ), + }; +} function isPersistedReferenceResourceId(resourceId: string | null | undefined) { const normalizedResourceId = resourceId?.trim(); @@ -488,9 +772,7 @@ export function isCanvasGenerationDialogLayoutItem( return ( item.itemType === 'generation-dialog' && Boolean( - hydrateCanvasGenerationDialog( - (item as { dialog?: unknown }).dialog, - ), + hydrateCanvasGenerationDialog((item as { dialog?: unknown }).dialog), ) ); } @@ -548,6 +830,7 @@ export function isUnresolvedCanvasGenerationDialogRecord( } return ( dialog.status === 'generating' || + dialog.status === 'pending-confirmation' || typeof dialog.generatedLayerId !== 'string' || dialog.generatedLayerId.trim() === '' ); @@ -570,7 +853,11 @@ export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000; function inlineGenerationPlaceholderExpiryAt( dialog: CanvasGenerationDialogState, ): number | null { - if (dialog.requiresLiveSession !== true || dialog.status !== 'generating') { + if ( + dialog.perfectPixelOperation || + dialog.requiresLiveSession !== true || + dialog.status !== 'generating' + ) { return null; } const startedAt = dialog.generationStartedAt; @@ -630,9 +917,9 @@ export function resolveNextInlineGenerationDialogExpiryAt( * A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前 * revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。) * - * 改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` 才判定为孤儿。 - * 不如结构性判据优雅,但那条判据在多标签页下本来就不成立。时间戳用 `generationStartedAt`, - * 它由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,无需新增字段。 + * 旧 inline 占位改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` + * 才判定为孤儿。带 `perfectPixelOperation` 请求账本的新占位不再走这条 legacy 清理,而由 + * GET-only 对账收口;即便账本损坏也要留给 hydrate 失败关闭,不能在这里静默删卡。 * * 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移 * 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 @@ -640,39 +927,87 @@ export function resolveNextInlineGenerationDialogExpiryAt( * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 */ +function canvasGenerationDialogRecord( + item: EditorProjectLayerSnapshot, +): Record | null { + if (item.itemType !== 'generation-dialog') { + return null; + } + const dialog = (item as { dialog?: unknown }).dialog; + return isSnapshotRecord(dialog) ? dialog : null; +} + +function canvasGenerationDialogMirrorKey(item: EditorProjectLayerSnapshot) { + const dialogId = stringOrNull(canvasGenerationDialogRecord(item)?.id); + return dialogId ? `dialog:${dialogId}` : `layer:${item.layerId}`; +} + +function isDeadLegacyInlineGenerationPlaceholder( + item: EditorProjectLayerSnapshot, + now: number, +) { + const dialog = canvasGenerationDialogRecord(item); + if (dialog?.requiresLiveSession !== true || dialog.status !== 'generating') { + return false; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 + // `generationStartedAt` 在同一次创建里一起写——但 legacy 兜底仍需避免永久孤儿。 + return ( + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) || + now - startedAt > INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS + ); +} + export function dropDeadInlineGenerationPlaceholders( project: EditorProjectSnapshot, now: number = Date.now(), ): { project: EditorProjectSnapshot; droppedCount: number } { - const nextLayers = project.layers.filter((item) => { - if (item.itemType !== 'generation-dialog') { - return true; - } - const dialog = - (item as { dialog?: unknown }).dialog && - typeof (item as { dialog?: unknown }).dialog === 'object' - ? ((item as { dialog?: unknown }).dialog as Record) - : null; - if ( - dialog?.requiresLiveSession !== true || - dialog?.status !== 'generating' - ) { - return true; - } - const startedAt = dialog.generationStartedAt; - // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 - // `generationStartedAt` 在同一次创建里一起写——但兜底方向必须选对:按「保留」会让 - // 这类占位永久留在画布上,而按「剥离」最坏只是退回本次改动之前的行为。 - if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { - return false; - } - return now - startedAt <= INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; - }); - const droppedCount = project.layers.length - nextLayers.length; + const mirroredLayers = [...project.layers, ...(project.canvas?.layers ?? [])]; + const operationBackedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const dialog = canvasGenerationDialogRecord(item); + return dialog && + Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation') + ? [canvasGenerationDialogMirrorKey(item)] + : []; + }), + ); + const droppedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const key = canvasGenerationDialogMirrorKey(item); + return !operationBackedDialogKeys.has(key) && + isDeadLegacyInlineGenerationPlaceholder(item, now) + ? [key] + : []; + }), + ); + const droppedCount = droppedDialogKeys.size; if (droppedCount === 0) { return { project, droppedCount: 0 }; } - return { project: { ...project, layers: nextLayers }, droppedCount }; + const dropFromMirror = (layers: EditorProjectLayerSnapshot[]) => + layers.filter( + (item) => + item.itemType !== 'generation-dialog' || + !droppedDialogKeys.has(canvasGenerationDialogMirrorKey(item)), + ); + return { + project: { + ...project, + layers: dropFromMirror(project.layers), + ...(project.canvas + ? { + canvas: { + ...project.canvas, + layers: dropFromMirror(project.canvas.layers), + }, + } + : {}), + }, + droppedCount, + }; } export function splitCanvasLayoutItems( @@ -730,6 +1065,25 @@ export function hydrateCanvasGenerationDialog( if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } + const hasPerfectPixelOperation = Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperation', + ); + const perfectPixelOperation = hasPerfectPixelOperation + ? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id) + : undefined; + const hasPersistedInvalidPerfectPixelOperationMarker = + Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationInvalid', + ); + const hasInvalidPerfectPixelOperation = + (hasPerfectPixelOperation && !perfectPixelOperation) || + hasPersistedInvalidPerfectPixelOperationMarker || + (snapshot.status === 'pending-confirmation' && !perfectPixelOperation); + const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation + ? undefined + : perfectPixelOperation; const style = snapshot.mode === 'generate' || snapshot.mode === 'character' || @@ -743,14 +1097,23 @@ export function hydrateCanvasGenerationDialog( id, mode: snapshot.mode, prompt, - status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', + status: hasInvalidPerfectPixelOperation + ? 'failed' + : isGenerationStatus(snapshot.status) + ? snapshot.status + : 'idle', // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, // 不会被 dropDeadInlineGenerationPlaceholders 误清。 - requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined, + requiresLiveSession: + snapshot.requiresLiveSession === true ? true : undefined, + ...(trustedPerfectPixelOperation + ? { perfectPixelOperation: trustedPerfectPixelOperation } + : {}), + ...(hasInvalidPerfectPixelOperation + ? { perfectPixelOperationInvalid: true } + : {}), composerOpen: - typeof snapshot.composerOpen === 'boolean' - ? snapshot.composerOpen - : true, + typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true, sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), generatedLayerId: stringOrUndefined(snapshot.generatedLayerId), specType: isSpecGenerationType(snapshot.specType) @@ -807,9 +1170,7 @@ export function hydrateCanvasGenerationDialog( imageModel: stringOrUndefined(snapshot.imageModel), style, videoModel: - typeof snapshot.videoModel === 'string' - ? snapshot.videoModel - : undefined, + typeof snapshot.videoModel === 'string' ? snapshot.videoModel : undefined, videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio), videoResolution: snapshot.videoResolution === '480p' || @@ -871,7 +1232,9 @@ export function hydrateCanvasGenerationDialog( audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds), aspectRatio: stringOrUndefined(snapshot.aspectRatio), imageSize: stringOrUndefined(snapshot.imageSize), - errorMessage: stringOrUndefined(snapshot.errorMessage), + errorMessage: hasInvalidPerfectPixelOperation + ? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE + : stringOrUndefined(snapshot.errorMessage), generationStartedAt: numberOrUndefined(snapshot.generationStartedAt), generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), @@ -929,13 +1292,17 @@ export function hydrateLayer( previewVideoPath: stringOrNull(snapshot.previewVideoPath), prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), actualPrompt: - stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt), + stringOrNull(snapshot.actualPrompt) ?? + stringOrNull(resource?.actualPrompt), model: resolveHydratedLayerModel(snapshot, resource, resourcesById), - provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), + provider: + stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), - objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), + objectKey: + stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), assetObjectId: - stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId), + stringOrNull(snapshot.assetObjectId) ?? + stringOrNull(resource?.assetObjectId), durationSeconds: audioDurationOrNull(snapshot.durationSeconds) ?? audioDurationOrNull(resource?.durationSeconds), @@ -984,9 +1351,7 @@ function resolveHydratedLayerModel( return persistedModel; } -export function mapAssetLibrarySnapshot( - library: EditorAssetLibrarySnapshot, -): { +export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { folders: EditorAssetFolder[]; assets: EditorAsset[]; } { @@ -1170,7 +1535,8 @@ export function soundDurationOrDefault( value: unknown, fallbackValue?: unknown, ) { - const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue); + const resolvedValue = + audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue); if (!resolvedValue) { return undefined; } @@ -1281,10 +1647,7 @@ export function resolveContextMenuPosition( }; } -export function hasDataTransferType( - dataTransfer: DataTransfer, - type: string, -) { +export function hasDataTransferType(dataTransfer: DataTransfer, type: string) { return Array.from(dataTransfer.types).includes(type); } @@ -1317,7 +1680,9 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { (layer.resourceId === assetSourceResourceId || layer.sourceResourceId === assetSourceResourceId), ) || - Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) || + Boolean( + asset.assetObjectId && layer.assetObjectId === asset.assetObjectId, + ) || Boolean(assetObjectKey && layerObjectKey === assetObjectKey) || Boolean(assetSrc && layerSrc === assetSrc) ); @@ -1422,7 +1787,12 @@ function isCanvasGenerationDialogMode( function isGenerationStatus( value: unknown, ): value is CanvasGenerationDialogState['status'] { - return value === 'idle' || value === 'generating' || value === 'failed'; + return ( + value === 'idle' || + value === 'generating' || + value === 'pending-confirmation' || + value === 'failed' + ); } function isSpecGenerationType( @@ -1473,9 +1843,7 @@ function hydrateSpecFormValues( artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '', bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '', characterView: - typeof snapshot.characterView === 'string' - ? snapshot.characterView - : '', + typeof snapshot.characterView === 'string' ? snapshot.characterView : '', customPrompt: typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '', }; @@ -1533,7 +1901,8 @@ function hydrateCharacterReference( return null; } const objectKey = - stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey); + stringOrUndefined(snapshot.objectKey) ?? + stringOrUndefined(resource?.objectKey); const assetObjectId = stringOrUndefined(snapshot.assetObjectId) ?? stringOrUndefined(resource?.assetObjectId); @@ -1861,8 +2230,7 @@ function snapItemsOverlapOnCrossAxis( const itemCrossStart = getSnapItemCrossStart(item, axis); const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis); return ( - proposedCrossStart <= - itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && + proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE ); } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index db5d2f59a..7b7c9848c 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -6,6 +6,7 @@ import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, EditorImageGenerationStyle, + EditorPixelArtSnapInput, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -182,6 +183,16 @@ export type PublicationMaterialsWorkflowId = | 'publication-detail-gallery' | 'publication-promo-poster'; +export type PerfectPixelOperationSnapshot = { + version: 1; + kind: 'perfect-pixel'; + operationId: string; + taskId: string; + request: EditorPixelArtSnapInput; + submittedAt: number; + reconcileUntil: number; +}; + export type GenerateDialogState = { id?: string; mode: @@ -199,7 +210,7 @@ export type GenerateDialogState = { | 'audio-background-music'; prompt: string; assetLabel?: string; - status: 'idle' | 'generating' | 'failed'; + status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; @@ -242,6 +253,8 @@ export type GenerateDialogState = { // 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后 // 必须原样恢复。 requiresLiveSession?: boolean; + perfectPixelOperation?: PerfectPixelOperationSnapshot; + perfectPixelOperationInvalid?: boolean; generationStartedAt?: number; generationFinishedAt?: number; placeholder?: { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ccd3aa11f..cacfdd478 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -749,6 +749,7 @@ export function ImageCanvasEditorView({ inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, + getCanvasGenerationDialogsSnapshot, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, @@ -1159,11 +1160,12 @@ export function ImageCanvasEditorView({ layersRef, viewportRef, canvasGenerationDialogsRef, + getCanvasGenerationDialogsSnapshot, canvasBackgroundColorRef, selectedLayerIdRef, selectedLayerIdsRef, }), - [], + [getCanvasGenerationDialogsSnapshot], ); const projectPersistenceSetters = useMemo( () => ({ @@ -1231,7 +1233,9 @@ export function ImageCanvasEditorView({ ) => { captureCanvasHistory(action); applyProjectSnapshot(project); + if (action.type !== 'perfect-pixel') { void refreshAssetLibrary(); + } }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], ); @@ -1504,6 +1508,7 @@ export function ImageCanvasEditorView({ assetFolderId: activeUploadFolderId, upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, + applyProjectSnapshotWithoutHistory: applyProjectSnapshot, flushProjectPersistence, refreshAssetLibrary, onWalletBalanceMayHaveChanged: refreshEditorWalletState, @@ -1627,6 +1632,7 @@ export function ImageCanvasEditorView({ snapSelectedLayerToPerfectPixels, activeInlineGenerationDialogIdsRef, perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splitSelectedIconSpritesheet, splittingIconSpritesheetLayerIds, extractUiDesignAssets, @@ -2413,6 +2419,7 @@ export function ImageCanvasEditorView({ generationComposerStyle, selectedToolbarStyle, perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splittingIconSpritesheetLayerIds, persistingAssetKindLayerIds, uploadDropTarget, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index fcf7d2ebe..78063dc2f 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -117,6 +117,109 @@ function renderComposer( } describe('ImageCanvasGenerationComposerView', () => { + it('待确认的完美像素操作只展示原操作重试,不回退到普通生成表单', () => { + const dialogId = 'dialog-perfect-pixel-pending'; + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: dialogId, + mode: 'generate', + prompt: '不应重新提交的普通提示词', + status: 'pending-confirmation', + composerOpen: true, + imageModel: 'gpt-image-2', + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_120_000, + }, + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect(within(panel).getByText('结果尚未确认,系统不会自动重复提交。')) + .toBeTruthy(); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull(); + + const retryButton = within(panel).getByRole('button', { + name: '重试同一完美像素操作', + }); + expect(within(panel).getAllByRole('button')).toEqual([retryButton]); + + fireEvent.click(retryButton); + + expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce(); + expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + + it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => { + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: 'dialog-perfect-pixel-invalid', + mode: 'generate', + prompt: '不得重新生成', + status: 'failed', + composerOpen: true, + imageModel: 'gpt-image-2', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照身份不匹配。', + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect( + within(panel).getByRole('alert').textContent, + ).toBe('完美像素操作快照身份不匹配。'); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByRole('button')).toBeNull(); + expect(within(panel).queryByText('不得重新生成')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + + fireEvent.submit(panel); + + expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled(); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + it('让快速编辑显示提示词、尺寸和模型选择', () => { renderComposer({ mode: 'quick-edit', diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 1756f8174..7f1693c81 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -122,6 +122,7 @@ type ImageCanvasGenerationComposerViewProps = { onSubmitQuickEdit: () => void; onSubmitCropExpand: () => void; onSubmitCharacterAnimation: () => void; + onRetryPerfectPixelOperation?: (dialogId: string) => void; onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onUpdateIconDescriptionText: (value: string) => void; onUpdateCharacterAnimationDuration: (frameCountValue: string) => void; @@ -130,6 +131,62 @@ type ImageCanvasGenerationComposerViewProps = { onSpecMenuPointerLeave?: () => void; }; +function PerfectPixelOperationPanel({ + dialog, + style, + onRetry, +}: { + dialog: GenerateDialogState; + style: CSSProperties; + onRetry?: (dialogId: string) => void; +}) { + const isPending = dialog.status === 'pending-confirmation'; + const isGenerating = dialog.status === 'generating'; + const isInvalid = dialog.perfectPixelOperationInvalid === true; + const message = isInvalid + ? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。') + : isPending + ? (dialog.errorMessage ?? + '结果尚未确认,系统不会自动重复提交。') + : isGenerating + ? '完美像素处理中' + : dialog.status === 'failed' + ? (dialog.errorMessage ?? '完美像素处理失败') + : '完美像素处理已完成'; + return ( +
event.stopPropagation()} + > + 完美像素 + + {message} + + {isPending && dialog.id && dialog.perfectPixelOperation ? ( + onRetry?.(dialog.id!)} + > + 重试同一完美像素操作 + + ) : null} +
+ ); +} + function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', @@ -947,6 +1004,7 @@ export function ImageCanvasGenerationComposerView({ onSubmitQuickEdit, onSubmitCropExpand, onSubmitCharacterAnimation, + onRetryPerfectPixelOperation, onUpdateSpecFormValue, onUpdateIconDescriptionText, onUpdateCharacterAnimationDuration, @@ -954,6 +1012,10 @@ export function ImageCanvasGenerationComposerView({ onSpecMenuPointerEnter, onSpecMenuPointerLeave, }: ImageCanvasGenerationComposerViewProps) { + const isPerfectPixelDialog = Boolean( + generateDialog?.perfectPixelOperation || + generateDialog?.perfectPixelOperationInvalid, + ); return ( <> {isSpecMenuOpen @@ -981,7 +1043,19 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {(generateDialog?.mode === 'generate' || + {isPerfectPixelDialog && + generateDialog && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + + {!isPerfectPixelDialog && + (generateDialog?.mode === 'generate' || generateDialog?.mode === 'quick-edit') && generateDialog.composerOpen !== false && generationComposerStyle ? ( diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index f59103f95..452b59125 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -78,7 +78,10 @@ function syncPanelFromDialogUpdate( model: normalizedDialog.imageModel ?? normalizedPanel.model, quickEditReferences: normalizedDialog.generationReferences, assetLabel: normalizedDialog.assetLabel, - status: normalizedDialog.status, + status: + normalizedDialog.status === 'pending-confirmation' + ? normalizedPanel.status + : normalizedDialog.status, errorMessage: normalizedDialog.errorMessage, }; } diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index d1cd1ed4c..bdced3445 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -44,6 +44,7 @@ function renderSelectedToolbar( onRemoveBackground: vi.fn(), onPerfectPixel: vi.fn(), isPerfectPixelProcessing: false, + isPerfectPixelPendingConfirmation: false, isSplittingIconSpritesheet: false, isPersistingAssetKind: false, onSplitIconSpritesheet: vi.fn(), @@ -226,6 +227,29 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onPerfectPixel).not.toHaveBeenCalled(); }); + it('renders a distinct disabled state while perfect pixel is pending confirmation', () => { + const props = renderSelectedToolbar({ + isPerfectPixelPendingConfirmation: true, + }); + const button = screen.getByRole('button', { + name: '完美像素结果待确认', + }); + + expect(button.getAttribute('title')).toBe( + '结果待确认,双击原占位继续核对或重试', + ); + expect(button.getAttribute('aria-busy')).toBe('false'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(button.textContent).toContain('待确认'); + expect(button.textContent).not.toContain('处理中'); + expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy(); + expect(button.querySelector('.lucide-loader-circle')).toBeNull(); + + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + it('does not invoke perfect pixel while the selected asset kind is persisting', () => { // 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时 // 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」 @@ -417,6 +441,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onRemoveBackground={vi.fn()} onPerfectPixel={vi.fn()} isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} @@ -438,6 +463,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onRemoveBackground={vi.fn()} onPerfectPixel={vi.fn()} isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index d8434e0e1..3cb296897 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -25,6 +25,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onRemoveBackground: (layer: CanvasLayer) => void; onPerfectPixel: (layer: CanvasLayer) => void; isPerfectPixelProcessing: boolean; + isPerfectPixelPendingConfirmation: boolean; isSplittingIconSpritesheet: boolean; isPersistingAssetKind: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; @@ -42,6 +43,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onRemoveBackground, onPerfectPixel, isPerfectPixelProcessing, + isPerfectPixelPendingConfirmation, isSplittingIconSpritesheet, isPersistingAssetKind, onSplitIconSpritesheet, @@ -134,6 +136,8 @@ export function ImageCanvasSelectedLayerToolbarView({ ? '完美像素等待素材类型保存' : isPerfectPixelProcessing ? '完美像素处理中' + : isPerfectPixelPendingConfirmation + ? '完美像素结果待确认' : '完美像素' } title={ @@ -141,6 +145,8 @@ export function ImageCanvasSelectedLayerToolbarView({ ? '完美像素等待素材类型保存' : isPerfectPixelProcessing ? '完美像素处理中' + : isPerfectPixelPendingConfirmation + ? '结果待确认,双击原占位继续核对或重试' : '自动识别并规整像素网格' } icon={ @@ -154,7 +160,11 @@ export function ImageCanvasSelectedLayerToolbarView({ // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={isPersistingAssetKind || isPerfectPixelProcessing} + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} onClick={() => onPerfectPixel(selectedLayer)} > @@ -163,6 +173,8 @@ export function ImageCanvasSelectedLayerToolbarView({ ? '保存中' : isPerfectPixelProcessing ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' : '完美像素'} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 2df1f9fc4..80f0d3216 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -79,6 +79,7 @@ export type ImageCanvasStageViewProps = { selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; perfectPixelLayerIds?: ReadonlySet; + pendingPerfectPixelLayerIds?: ReadonlySet; persistingAssetKindLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; @@ -235,6 +236,7 @@ export function ImageCanvasStageView({ selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, perfectPixelLayerIds = EMPTY_LAYER_ID_SET, + pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET, persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, @@ -409,6 +411,10 @@ export function ImageCanvasStageView({ isPerfectPixelProcessing={Boolean( selectedLayer && perfectPixelLayerIds.has(selectedLayer.id), )} + isPerfectPixelPendingConfirmation={Boolean( + selectedLayer && + pendingPerfectPixelLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 446a3fa26..a4c1ed6b6 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ resolvedUrl: - options?.objectKey === 'generated-character-drafts/editor/uploaded.png' + options?.objectKey === + 'generated-character-drafts/editor/uploaded.png' ? 'https://oss.example.com/uploaded.png?signature=1' : _source, isResolving: false, @@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('生成中')).toBeTruthy(); const world = document.querySelector('.image-canvas-editor__world'); - const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + const marquee = world?.querySelector( + '.image-canvas-editor__canvas-marquee', + ); expect(world?.getAttribute('style')).toContain( 'transform: translate(10px, 20px) scale(1.5)', @@ -378,7 +381,11 @@ describe('ImageCanvasWorldView', () => { expect(within(frame).getByText('Icon Generator')).toBeTruthy(); expect(within(frame).getByText('图标')).toBeTruthy(); expect(within(frame).getByText('1024 x 768')).toBeTruthy(); - expect(within(frame).getByRole('status').textContent).toBe('生成中'); + const generatingStatus = within(frame).getByRole('status'); + expect(generatingStatus.textContent).toBe('生成中'); + expect(generatingStatus.className).not.toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); fireEvent.pointerDown(frame); fireEvent.doubleClick(frame); @@ -394,6 +401,30 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); + it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => { + const dialog = createGenerationDialog({ + id: 'dialog-perfect-pixel-pending', + status: 'pending-confirmation', + }); + + renderWorldView({ + canvasGenerationDialogs: [dialog], + generateDialog: null, + }); + + const frame = screen.getByRole('button', { name: '图像生成占位图' }); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--pending-confirmation', + ); + expect(within(frame).getByText('Image Generator')).toBeTruthy(); + const pendingStatus = within(frame).getByRole('status'); + expect(pendingStatus.textContent).toBe('结果待确认'); + expect(pendingStatus.className).toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); + expect(within(frame).queryByText('生成中')).toBeNull(); + }); + it('renders crop-expand frame handles and forwards drag actions', () => { const layer = createLayer(); const { props } = renderWorldView({ @@ -530,7 +561,14 @@ describe('ImageCanvasWorldView', () => { }, ])( 'renders $mode generation placeholder with its own icon and badge', - ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => { + ({ + mode, + ariaLabel, + generatorLabel, + badgeLabel, + frameClassName, + iconClassName, + }) => { const dialog = createGenerationDialog({ id: `dialog-${mode}`, mode: mode as CanvasGenerationDialogState['mode'], @@ -614,9 +652,9 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(generatorLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( mode === 'audio-sound-effect' || mode === 'audio-background-music' @@ -630,9 +668,9 @@ describe('ImageCanvasWorldView', () => { : inverseScale, ); expect( - (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(badgeLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }, ); @@ -718,14 +756,14 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('角色') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('640 x 480 px') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( within(layerButton) @@ -733,14 +771,14 @@ describe('ImageCanvasWorldView', () => { .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('Image Generator') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('1024 x 768') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }); @@ -755,8 +793,11 @@ describe('ImageCanvasWorldView', () => { const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect( - (frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ( + frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe('4'); }); @@ -813,9 +854,9 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain( - 'lucide-info', - ); + expect( + metadataButton.querySelector('svg')?.getAttribute('class'), + ).toContain('lucide-info'); }); it('renders audio layers with an audio control card instead of an image', () => { @@ -849,7 +890,8 @@ describe('ImageCanvasWorldView', () => { within(layerButton).getAllByRole('button', { name: /播放|暂停/u }), ).toHaveLength(1); expect( - within(layerButton).getByRole('button', { name: '播放游戏音效' }) + within(layerButton) + .getByRole('button', { name: '播放游戏音效' }) .querySelector('svg') ?.getAttribute('class'), ).toContain('lucide-volume-2'); @@ -872,9 +914,7 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy(); expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy(); - expect( - within(layerButton).queryByText('420 x 120 px'), - ).toBeNull(); + expect(within(layerButton).queryByText('420 x 120 px')).toBeNull(); }); it('keeps audio controls interactive and switches icon, play and pause states', () => { @@ -895,7 +935,9 @@ describe('ImageCanvasWorldView', () => { selectedLayerIds: [layer.id], }); - const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' }); + const layerButton = screen.getByRole('button', { + name: '选择游戏背景音乐', + }); const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐'); const playbackButton = within(layerButton).getByRole('button', { name: '播放游戏背景音乐', @@ -932,9 +974,8 @@ describe('ImageCanvasWorldView', () => { ?.getAttribute('class'), ).toContain('lucide-play'); - const hoveredAudio = within(hoveredLayerButton).getByLabelText( - '画布音频:游戏背景音乐', - ); + const hoveredAudio = + within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐'); fireEvent.play(hoveredAudio); expect( within(hoveredLayerButton) @@ -1032,9 +1073,7 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); fireEvent.pointerDown(document.body); - expect( - screen.queryByRole('menu', { name: '选择素材标签' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull(); } finally { document.removeEventListener('wheel', documentWheel); } @@ -1100,7 +1139,9 @@ describe('ImageCanvasWorldView', () => { layerButton.querySelector('.image-canvas-editor__media-preview--video') ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); - expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull(); + expect( + within(layerButton).queryByAltText('画布视频:生成视频 7'), + ).toBeNull(); expect(within(layerButton).getByText('视频')).toBeTruthy(); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), @@ -1201,8 +1242,7 @@ describe('ImageCanvasWorldView', () => { it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { vi.useFakeTimers(); try { - useResolvedAssetReadUrlMock.mockImplementation( - (source: string) => ({ + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ resolvedUrl: source === '/generated-character-drafts/editor/frame01.png' ? 'https://oss.example.com/frame01.png?signature=1' @@ -1210,8 +1250,7 @@ describe('ImageCanvasWorldView', () => { isResolving: source === '/generated-character-drafts/editor/frame02.png', shouldResolve: true, - }), - ); + })); const layer = createLayer({ title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', @@ -1240,9 +1279,8 @@ describe('ImageCanvasWorldView', () => { renderWorldView({ layers: [layer] }); const layerButton = screen.getByRole('button', { name: '选择角色动作' }); - const firstFrame = within(layerButton).getByAltText( - '画布序列帧:角色动作', - ); + const firstFrame = + within(layerButton).getByAltText('画布序列帧:角色动作'); fireEvent.load(firstFrame); expect( @@ -1292,8 +1330,6 @@ describe('ImageCanvasWorldView', () => { }); expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); - expect( - screen.queryByRole('button', { name: '图像生成占位图' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 97ef196df..bdb56afb5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -316,9 +316,7 @@ function selectMediaLayerWithoutDrag( onSelectLayer(); } -function handleLayerKeyboardActivation( - event: ReactKeyboardEvent, -) { +function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { if (event.key !== 'Enter' && event.key !== ' ') { return; } @@ -364,7 +362,9 @@ function getAudioLayerIcon(layer: CanvasLayer) { ); } -function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) { +function getAudioDurationSeconds( + durationSeconds: CanvasLayer['durationSeconds'], +) { return typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 @@ -485,7 +485,9 @@ function ImageCanvasAudioLayerCard({ event.stopPropagation(); void togglePlayback(); }} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } > {showPlaybackButton ? ( isPlaying ? ( @@ -504,7 +506,9 @@ function ImageCanvasAudioLayerCard({ className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated" onClick={stopMediaEventPropagation} onKeyDown={stopMediaControlKeyPropagation} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } >