diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4c87cfcae..7cf0063a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5982,5 +5982,17 @@ - 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。 - 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。 - 文案边界:对账尾句只下指令、不断言素材库已刷新。`refreshAssetLibrary` 在 `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时弹登录框、其余一律吞掉,返回 `Promise` 不带成败信号,且它本身是可选 prop——三种情况下「已刷新」都是假话,会让用户对着旧列表判定「没有派生图,可以重试」,重新走回这条修复要避免的重复创建。刷新照旧调用(成功时用户白赚一份新列表),但文案在刷新失效时也必须成立。 -- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口,需先给 dialog 增加「无 durable job 的 inline 链路」标记,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口(已由 2026-08-03 的 `requiresLiveSession` 条目解决)。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素刷新后的孤儿占位由归属标记收口 + +- 缺陷:完美像素在 POST 前 `flushProjectPersistence()` 把 `status: 'generating'` 的占位落库,随后走同步 HTTP。此时刷新页面,浏览器断连、handler future 被丢弃,服务端不会走完画布回填;新页面 hydrate 时 `status` 被原样还原(`isGenerationStatus` 认 `generating`),而加载期没有任何对账、轮询或重试 GET,占位就永久停在转圈状态。它还消不掉——Esc 被 `useImageCanvasKeyboardShortcuts` 的 `status === 'generating'` 挡,composer 关闭被 `closeCanvasGenerationComposer` 的同一判断挡。 +- 归因:序列化设施是存量,但这个组合是本分支首次出现。同类路径逐条比对——去除背景的 `EditorBackgroundRemovalResult` 的 `queueState` 是非可选字段,恒走 durable job,worker 会在服务端替换占位;拆分图集根本不创建占位;图片生成等提交类链路在默认 `ExternalGenerationMode::Queue` 下同样入队,只有显式设 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 的部署才同步执行。完美像素的 handler 353 行全同步,`tokio::spawn` / `enqueue_` 各 0 次,`EditorPixelArtSnapResult` 无 `queueState`,是唯一「持久化 generating + 无 durable job」的链路。 +- 方案取舍:不能从写入侧解决。`validate_editor_pixel_art_snap_placeholder_exists` 要求占位必须已落库,否则返回 `409`「完美像素画布占位不存在或尚未保存,请重试」——不持久化占位会让每一次完美像素都失败。POST 前那句 flush 正是为满足该门禁而存在。因此只能在读取侧收口,纯前端。 +- 决策:给 dialog 增加 `requiresLiveSession` 标记,加载时由 `dropDeadInlineGenerationPlaceholders` 剥离置位且仍为 `generating` 的占位。判据是结构性不变量而非时间阈值:这类占位的收口只能由创建它的会话完成,而活着的那一份始终在内存里、永远不经过快照 hydrate,所以凡是从服务端快照读回来的必然属于已死会话。因此不需要时间戳,也不必猜阈值。队列型占位一律不置位——它们的 job 在服务端继续跑,误清会让用户以为操作没发生而重复提交。hydrate 只认布尔 `true`,缺字段的历史占位按队列型处理,不会被误清。 +- 作用域:剥离只用在项目首次加载的两个调用点(会话缓存与权威快照都要,否则首屏会先闪一个永远转圈的占位)。**不能**下沉进 `hydrateCanvasGenerationDialog`——会话内 `applyQueuedEditorGenerationProject` 也会重新 GET 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。唯一置位点是完美像素占位的创建处。 +- 提示文案:服务端持久化顺序 `OSS PUT → asset object → project resource → editor asset → 画布回填` 是非事务的,加载时还看到 `generating` 只说明最后一步没做完,前面几步可能已成功。所以不能断言「什么都没发生」,只提示「画布占位已清理,请确认素材库是否已生成派生图」,与同链路的对账文案同一口径。计数用累计值而非布尔——同一会话可能连着切换多个项目,布尔只提示一次。 +- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。多标签页同时编辑同一画布另有 CAS `expected_revision` 兜底,不由本次修复承担。 +- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 43ed3ea5b..c6d4078a3 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from 'vitest'; +import type { + EditorProjectLayerSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, @@ -993,4 +998,101 @@ describe('ImageCanvasEditorModel', () => { horizontal: 160, }); }); + + describe('dropDeadInlineGenerationPlaceholders', () => { + const buildProject = ( + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + projectId: 'project-1', + title: '画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers, + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }); + + const buildDialogItem = ( + id: string, + dialog: Record, + ): EditorProjectLayerSnapshot => + ({ + itemType: 'generation-dialog', + layerId: `generation-dialog:${id}`, + resourceId: `generation-dialog:${id}`, + dialog: { id, mode: 'quick-edit', prompt: '完美像素', ...dialog }, + }) as unknown as EditorProjectLayerSnapshot; + + it('drops generating placeholders whose only owner was a dead session', () => { + const project = buildProject([ + buildDialogItem('dead', { + status: 'generating', + requiresLiveSession: true, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('keeps generating placeholders backed by a durable job', () => { + // 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会 + // 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。 + const project = buildProject([ + buildDialogItem('queued', { status: 'generating' }), + buildDialogItem('queued-explicit-false', { + status: 'generating', + requiresLiveSession: false, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps settled inline placeholders and unrelated layout items', () => { + const settled = buildDialogItem('settled', { + status: 'failed', + requiresLiveSession: true, + }); + const imageLayer = { + itemType: 'layer', + layerId: 'layer-1', + resourceId: 'resource-1', + } as unknown as EditorProjectLayerSnapshot; + const project = buildProject([settled, imageLayer]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project.layers).toEqual([settled, imageLayer]); + }); + + it('round-trips the marker through hydration so reloads can still detect it', () => { + // 中文注释:hydrate 是白名单式的,漏掉这个字段会让标记在一次「加载→保存」后消失, + // 孤儿占位重新变得不可识别。 + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + requiresLiveSession: true, + }); + + expect(hydrated?.requiresLiveSession).toBe(true); + + const [item] = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + expect( + (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 8225f6ccb..e755e8226 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -2,6 +2,7 @@ import type { EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, EditorProjectLayerSnapshot, + EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, @@ -500,6 +501,39 @@ function isCanvasSettingsLayoutItem( return item.itemType === 'canvas-settings'; } +/** + * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 + * + * 判据是一条结构性不变量,不是时间阈值:置位了 requiresLiveSession 的占位,其收口只能由 + * 创建它的页面会话完成;而活着的那一份始终在内存里,永远不经过快照 hydrate。因此凡是从 + * 服务端快照里读回来的这类 generating 占位,创建它的会话必然已经不在了。 + * + * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, + * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 + */ +export function dropDeadInlineGenerationPlaceholders( + project: EditorProjectSnapshot, +): { 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; + return !( + dialog?.requiresLiveSession === true && dialog?.status === 'generating' + ); + }); + const droppedCount = project.layers.length - nextLayers.length; + if (droppedCount === 0) { + return { project, droppedCount: 0 }; + } + return { project: { ...project, layers: nextLayers }, droppedCount }; +} + export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), @@ -569,6 +603,9 @@ export function hydrateCanvasGenerationDialog( mode: snapshot.mode, prompt, status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', + // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, + // 不会被 dropDeadInlineGenerationPlaceholders 误清。 + requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined, composerOpen: typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 44b54d4a7..db5d2f59a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -237,6 +237,11 @@ export type GenerateDialogState = { aspectRatio?: string; imageSize?: string; errorMessage?: string; + // 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有 + // durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、 + // 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后 + // 必须原样恢复。 + requiresLiveSession?: boolean; generationStartedAt?: number; generationFinishedAt?: number; placeholder?: { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 723db96d2..37eb6c7b8 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1184,6 +1184,7 @@ export function ImageCanvasEditorView({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, @@ -1497,6 +1498,17 @@ export function ImageCanvasEditorView({ generationSurface.refreshTaskList(); }, [generationSurface]); const showGenerationWarning = generationSurface.showGenerationWarning; + useEffect(() => { + if (deadInlinePlaceholderDropCount === 0) { + return; + } + // 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset + // → 画布收口,非事务。加载时还看到 generating 只能说明最后一步没做完,前面几步可能已经 + // 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。 + showGenerationWarning( + '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。', + ); + }, [deadInlinePlaceholderDropCount, showGenerationWarning]); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 7d1062b22..544a18958 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -255,6 +255,12 @@ function GenerationWorkflowHarness({ ) .join('|') || '-'} + + {dialogs.canvasGenerationDialogs + .filter((dialog) => dialog.requiresLiveSession === true) + .map((dialog) => dialog.id) + .join('|') || '-'} + {activeDialog?.generationReferences ?.map((reference) => reference.label) @@ -2141,6 +2147,9 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('generation-dialogs').textContent).toContain( 'generation-dialog-1:quick-edit:closed', ); + // 中文注释:去除背景恒返回 queueState,job 在服务端继续跑,刷新后 worker 会替换占位。 + // 置位 requiresLiveSession 会让加载期把还在跑的占位清掉,因此必须保持未置位。 + expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-'); expect(removeImageBackgroundMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'resource-source', @@ -2240,6 +2249,12 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(events).toEqual(['flush', 'post']); expect(flushProjectPersistence).toHaveBeenCalledTimes(1); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); + // 中文注释:完美像素是同步 HTTP、服务端无 durable job,占位的收口只能由本页会话完成。 + // 标记必须真的落在提交出去的那份占位上——它会随 flush 一起持久化,是刷新后唯一能把 + // 孤儿占位和队列型占位区分开的依据。漏置位就退回「刷新后永久转圈」。 + expect(screen.getByTestId('live-session-dialogs').textContent).toBe( + 'generation-dialog-1', + ); expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated-images/editor/source.png', diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 5ca2c952a..43b404216 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1800,6 +1800,11 @@ export function useImageCanvasGenerationWorkflow({ }, }), composerOpen: false, + // 中文注释:完美像素是同步 HTTP,服务端不建 durable job(见 snap_editor_image_to_pixel_art + // 无 enqueue / spawn)。刷新掉本页会话后,没有任何东西会把这个占位推向终态,而 + // 服务端 409 门禁又要求占位必须先落库,所以只能在读取侧收口:置位后由项目首次 + // 加载时的 dropDeadInlineGenerationPlaceholders 清掉。 + requiresLiveSession: true, }); perfectPixelDialogId = placement.dialogId; if (!placement.placeholder) { diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index f19245d95..3dc30ccd3 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -22,6 +22,7 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, hydrateLayer, isInlineEditorMediaSource, serializeCanvasLayout, @@ -430,6 +431,10 @@ export function useImageCanvasProjectPersistence({ const coverSnapshotViewportSizeRef = useRef(canvasSize); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); + // 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位, + // 布尔只会提示一次。调用方以计数变化为触发条件。 + const [deadInlinePlaceholderDropCount, setDeadInlinePlaceholderDropCount] = + useState(0); const { setProjectTitle, setProjectRenameValue, @@ -1193,17 +1198,31 @@ export function useImageCanvasProjectPersistence({ currentUserId, ); if (cachedProject) { - applyProjectSnapshot(cachedProject.project, { authoritative: false }); + // 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位, + // 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。 + applyProjectSnapshot( + dropDeadInlineGenerationPlaceholders(cachedProject.project).project, + { authoritative: false }, + ); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject - .then((project) => { + .then((loadedProject) => { if (cancelled) { return; } + // 中文注释:只在这里剥离。会话内 applyQueuedEditorGenerationProject 也会重新 GET + // 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。 + const { project, droppedCount } = + dropDeadInlineGenerationPlaceholders(loadedProject); + if (droppedCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + droppedCount, + ); + } const projectIsAuthoritative = applyProjectSnapshot(project, { allowProjectSwitch: true, }); @@ -1327,5 +1346,6 @@ export function useImageCanvasProjectPersistence({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, }; }