完美像素请求账本移出项目布局
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 8m36s

账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在
用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局
保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。

改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留
perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是
同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证
——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变
失败语义的 preferLatestGenerationDialogs。

由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历
内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清
账本。

本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、
配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户
删掉重来即可。跨设备不再自动收口是已知且接受的代价。

布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性
判死;写入侧不再产生新的内联账本。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 05:55:06 +00:00
parent 06a6c33ea6
commit 2fa2f245c1
11 changed files with 910 additions and 987 deletions
@@ -116,6 +116,11 @@ import {
type UiAssetExtractionTool,
updateUiAssetExtractionDraftMark,
} from './ImageCanvasUiAssetExtractionModel';
import {
forgetPerfectPixelOperation,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
@@ -220,17 +225,15 @@ function createProjectLayerSnapshotFromLayer(
};
}
// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。
// 中文注释:源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整
// 上传;confirm 成功后已经有稳定 objectKey,operation 请求随即形成。
//
// 源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整上传;confirm
// 成功后已经有稳定 objectKey,operation 请求随即形成。此后 strict layout save 使用独立
// 60 秒绝对 deadline,不能继续消耗源上传的余额,也不能在本地放弃等待后继续占住保存队列。
// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一
// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。
//
// strict ACK 之前 POST 必须为零。此阶段失败保留 `failed + perfectPixelOperation`,只允许
// 原样重试同一 request/source object;不能退回普通按钮创建新 identity,也不能执行结果 GET
// 或声称派生素材可能已落库。
// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待
// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。
export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000;
export const PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS = 60_000;
const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000;
const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000];
@@ -931,10 +934,7 @@ type GenerationWorkflowOptions = {
project: EditorProjectSnapshot,
) => boolean | void;
flushProjectPersistence?: (options?: {
requireSuccess?: boolean;
requiredDialogId?: string;
deadlineAt?: number;
signal?: AbortSignal;
preferLatestGenerationDialogs?: boolean;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
@@ -2145,6 +2145,13 @@ export function useImageCanvasGenerationWorkflow({
if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') {
return false;
}
// 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的
// 对账 GET,并占住保留期名额。
forgetPerfectPixelOperation(
currentUserId,
operation.request.projectId,
operation.operationId,
);
const confirmedAsset = resolveConfirmedPerfectPixelAsset(
result,
operation,
@@ -2177,6 +2184,7 @@ export function useImageCanvasGenerationWorkflow({
},
[
applyProjectSnapshot,
currentUserId,
hasCanvasGenerationDialogById,
refreshPerfectPixelAssetLibrary,
setActiveSidebarPanel,
@@ -2355,13 +2363,22 @@ export function useImageCanvasGenerationWorkflow({
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
requiresLiveSession: undefined,
perfectPixelOperationId: perfectPixelDialogId,
perfectPixelOperation,
}));
// 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端
// 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样
// 把布局校验失败升级成完美像素的硬阻断。
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelOperation,
);
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
await flushProjectPersistence({
requireSuccess: true,
requiredDialogId: perfectPixelDialogId,
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
});
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
@@ -2469,7 +2486,7 @@ export function useImageCanvasGenerationWorkflow({
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。`
: perfectPixelOperation && !perfectPixelPostAttempted
? `完美像素请求尚未发出:${serverMessage ?? '提交前画布保存失败'} 请在原占位重试同一操作。`
? `完美像素请求尚未发出:${serverMessage ?? '提交前准备失败'} 请在原占位重试,或删除占位后重来。`
: (serverMessage ?? '完美像素处理失败');
if (
perfectPixelDialogId &&
@@ -2481,10 +2498,16 @@ export function useImageCanvasGenerationWorkflow({
errorMessage,
}));
} else {
// 中文注释:durable operation 的普通删除路径已被禁止,但占位仍可能在快照写入前
// 失败、被其它权威项目更新移除,或来自旧会话状态。没有本地 dialog 可挂错误时必须
// 退回全局提示,不能静默开放一条新 identity 的重提路径。
// 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自
// 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。
showGenerationWarning(errorMessage);
if (perfectPixelDialogId) {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelDialogId,
);
}
}
} finally {
if (perfectPixelDialogId) {
@@ -2589,13 +2612,17 @@ export function useImageCanvasGenerationWorkflow({
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: retriedOperation,
}));
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
await flushProjectPersistence({
requireSuccess: true,
requiredDialogId: normalizedDialogId,
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
});
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
@@ -2732,19 +2759,36 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelRecoveryControllersRef.current.clear();
return;
}
const recoverableDialogs = canvasGenerationDialogs.filter(
(dialog) =>
Boolean(dialog.perfectPixelOperation) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation'),
const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) =>
dialog.perfectPixelOperation &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
: [],
);
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
const recoverableDialogIds = new Set(
canvasGenerationDialogs.map((dialog) => dialog.id),
);
const nowMs = Date.now();
for (const [operationId, operation] of readPerfectPixelOperations(
currentUserId,
normalizedProjectId,
)) {
if (
recoverableDialogIds.has(operationId) ||
operation.reconcileUntil <= nowMs
) {
continue;
}
recoverableOperations.push({ dialogId: operationId, operation });
}
const recoveryKeys = new Set(
recoverableDialogs.map((dialog) =>
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
dialog.perfectPixelOperation!,
),
recoverableOperations.map(({ operation }) =>
perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation),
),
);
for (const [
@@ -2757,8 +2801,7 @@ export function useImageCanvasGenerationWorkflow({
}
}
for (const dialog of recoverableDialogs) {
const operation = dialog.perfectPixelOperation!;
for (const { dialogId, operation } of recoverableOperations) {
const recoveryKey = perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
@@ -2766,7 +2809,7 @@ export function useImageCanvasGenerationWorkflow({
);
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
if (hasActiveInlineGenerationDialog(dialog.id)) {
if (hasActiveInlineGenerationDialog(dialogId)) {
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
continue;
}
@@ -2781,7 +2824,12 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
void (async () => {
if (operation.request.projectId !== normalizedProjectId) {
updateCanvasGenerationDialogById(dialog.id, (current) => {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
updateCanvasGenerationDialogById(dialogId, (current) => {
if (
!isSameUnsettledPerfectPixelOperation(
current,
@@ -2817,15 +2865,23 @@ export function useImageCanvasGenerationWorkflow({
) {
return;
}
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
}
if (verdict.kind === 'applied') {
if (!hasCanvasGenerationDialogById(dialog.id)) {
if (!hasCanvasGenerationDialogById(dialogId)) {
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
const applied = applyProjectSnapshotWithoutHistory(verdict.project);
if (applied === false) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
@@ -2844,8 +2900,8 @@ export function useImageCanvasGenerationWorkflow({
return;
}
if (verdict.kind === 'dialog-missing') {
if (hasCanvasGenerationDialogById(dialog.id)) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
if (hasCanvasGenerationDialogById(dialogId)) {
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
@@ -2858,7 +2914,7 @@ export function useImageCanvasGenerationWorkflow({
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
? {
...current,
@@ -2881,9 +2937,9 @@ export function useImageCanvasGenerationWorkflow({
.catch((error: unknown) => {
if (
!controller.signal.aborted &&
hasCanvasGenerationDialogById(dialog.id)
hasCanvasGenerationDialogById(dialogId)
) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,