diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 6c64a70c0..f8b06bcc9 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -6428,3 +6428,14 @@
- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。
- 影响范围:仅文档。不改代码、不改测试。
- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。
+
+## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制
+
+- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。
+- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。
+- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。
+- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。
+- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。
+- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。
+- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
+- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。
diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
index 0d370c660..cab94fe38 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -180,7 +180,7 @@
- 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。
- 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。
- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。
-- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。
+- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。
- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
index a18d8626d..b88fc4dc9 100644
--- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
+++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
@@ -12,12 +12,15 @@ import type {
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
+ serializeCanvasLayout,
+ splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
+import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
mergeAuthoritativeCanvasLayoutWithPendingLocalLayout,
@@ -1023,6 +1026,120 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
+ it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => {
+ // 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但
+ // 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本——
+ // 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去
+ // exact retry 的 identity。
+ window.localStorage.clear();
+ const dialogId = 'generation-dialog-legacy-inflight';
+ const submittedAt = Date.now();
+ const legacyOperation = {
+ version: 1 as const,
+ kind: 'perfect-pixel' as const,
+ operationId: dialogId,
+ taskId: `pixel-art-snap-${dialogId}`,
+ request: {
+ sourceImageSrc: 'generated-images/editor/legacy-source.png',
+ projectId: 'editor-project-default',
+ assetLabel: '源图 · 完美像素',
+ canvasCompletion: {
+ dialogId,
+ title: '源图 · 完美像素',
+ placeholder: { ...STRICT_PLACEHOLDER },
+ },
+ },
+ submittedAt,
+ reconcileUntil: submittedAt + 75_000,
+ };
+ const legacyDialogItem = {
+ itemType: 'generation-dialog',
+ layerId: `generation-dialog:${dialogId}`,
+ resourceId: `generation-dialog:${dialogId}`,
+ dialog: {
+ id: dialogId,
+ mode: 'quick-edit',
+ prompt: '完美像素',
+ status: 'pending-confirmation',
+ composerOpen: false,
+ sourceLayerId: 'layer-source',
+ perfectPixelOperation: legacyOperation,
+ placeholder: { ...STRICT_PLACEHOLDER },
+ },
+ } as unknown as EditorProjectLayerSnapshot;
+ loadOrCreateRecentEditorProjectMock.mockResolvedValue({
+ projectId: 'editor-project-default',
+ title: '空画布项目',
+ canvas: {
+ canvasId: 'editor-project-default:canvas:default',
+ projectId: 'editor-project-default',
+ title: '默认画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [legacyDialogItem],
+ revision: 3,
+ layoutStorageVersion: 0,
+ updatedAt: '2026-08-05T00:00:00.000Z',
+ },
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [legacyDialogItem],
+ resources: [],
+ updatedAt: '2026-08-05T00:00:00.000Z',
+ });
+
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('strict-project-id').textContent).toBe(
+ 'editor-project-default',
+ );
+ });
+
+ await waitFor(() => {
+ expect(
+ readPerfectPixelOperations('user-test', 'editor-project-default').size,
+ ).toBe(1);
+ });
+ const migrated = readPerfectPixelOperations(
+ 'user-test',
+ 'editor-project-default',
+ ).get(dialogId);
+ expect(migrated).toEqual(legacyOperation);
+
+ // 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。
+ const strippedLayout = serializeCanvasLayout({
+ layers: [],
+ canvasGenerationDialogs: [
+ {
+ id: dialogId,
+ mode: 'quick-edit',
+ prompt: '完美像素',
+ status: 'pending-confirmation',
+ composerOpen: false,
+ perfectPixelOperationId: dialogId,
+ perfectPixelOperation: migrated,
+ } as unknown as CanvasGenerationDialogState,
+ ],
+ });
+ expect(JSON.stringify(strippedLayout)).not.toContain(
+ '"perfectPixelOperation"',
+ );
+ const { generationDialogs } = splitCanvasLayoutItems(
+ strippedLayout,
+ new Map(),
+ 'user-test',
+ readPerfectPixelOperations('user-test', 'editor-project-default'),
+ );
+ expect(generationDialogs[0]).toMatchObject({
+ id: dialogId,
+ status: 'pending-confirmation',
+ });
+ expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
+ legacyOperation,
+ );
+ expect(generationDialogs[0]).not.toHaveProperty(
+ 'perfectPixelOperationInvalid',
+ );
+ });
+
it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => {
render();
await waitFor(() => {
diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts
index d080f5b07..aac0e834e 100644
--- a/src/components/image-editor/useImageCanvasProjectPersistence.ts
+++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts
@@ -49,7 +49,10 @@ import {
firstSelectedLayerId,
normalizeCanvasSelectionIds,
} from './ImageCanvasSelectionModel';
-import { readPerfectPixelOperations } from './perfectPixelOperationStore';
+import {
+ readPerfectPixelOperations,
+ savePerfectPixelOperation,
+} from './perfectPixelOperationStore';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
@@ -1343,6 +1346,26 @@ export function useImageCanvasProjectPersistence({
currentUserId,
localPerfectPixelOperations,
);
+ // 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成
+ // `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移
+ // 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact
+ // retry 的 identity。只补写缺失的(本机那份可能刚被重新锚定过,比布局里的新),也只
+ // 补写未收口的(收口态本就不需要账本)。
+ for (const dialog of generationDialogs) {
+ const operation = dialog.perfectPixelOperation;
+ if (
+ operation &&
+ !localPerfectPixelOperations.has(dialog.id) &&
+ (dialog.status === 'generating' ||
+ dialog.status === 'pending-confirmation')
+ ) {
+ savePerfectPixelOperation(
+ currentUserId,
+ project.projectId,
+ operation,
+ );
+ }
+ }
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));