diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index caba9815e..863a04494 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -55,6 +55,14 @@ - 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +## 图片编辑器宣发素材生成器刷新后不要丢快照 + +- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。 +- 原因:画布布局把生成器保存为 `itemType: "generation-dialog"`,但恢复白名单漏掉 `publication` 模式和 `publicationWorkflowId` / `publicationGameInfo` / `publicationReferences` 字段,导致整条生成器快照被当成无效布局项丢弃。 +- 处理:`hydrateCanvasGenerationDialog` 必须把 `publication` 视为正式画布生成器模式,并显式恢复宣发素材专属字段;组件层应断言刷新回读项目快照后仍显示卡片类型、字段和参考图。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter verbose`。 +- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx`、`src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 + ## 图片画布快速编辑不要直接提交普通图片 URL - 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 368850adc..0e3dd7bed 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -43,7 +43,7 @@ - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -90,7 +90,7 @@ - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 -- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。 +- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9 · 2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 - 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 304584f11..2e3e19db7 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -276,6 +276,79 @@ describe('ImageCanvasEditorView generation integration', () => { ); }); + it('restores publication material composer fields and references from saved generator snapshots', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-publication-dialog', + title: '宣发画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-publication', + resourceId: 'generation-dialog:generation-dialog-publication', + dialog: { + id: 'generation-dialog-publication', + mode: 'publication', + prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', + status: 'idle', + composerOpen: true, + publicationWorkflowId: 'publication-promo-poster', + publicationGameInfo: { + gameName: '马戏团午夜惊魂', + gameCategories: '非对称对抗', + gameDescription: '找到钥匙,开门逃离马戏团', + }, + publicationReferences: [ + { + id: 'publication-reference-1', + label: '首图参考', + src: '/generated-character-drafts/editor/reference.png', + objectKey: 'generated-character-drafts/editor/reference.png', + assetObjectId: 'asset-object-publication-reference', + }, + ], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + placeholder: { + x: 282, + y: 112, + width: 1280, + height: 720, + originalWidth: 1280, + originalHeight: 720, + }, + }, + }, + ], + resources: [], + updatedAt: '2026-06-17T00:00:00.000Z', + }); + + render(); + + const publicationDialog = await screen.findByRole('dialog', { + name: '运营海报生成卡片', + }); + expect( + (within(publicationDialog).getByRole('textbox', { + name: '运营海报游戏名', + }) as HTMLInputElement).value, + ).toBe('马戏团午夜惊魂'); + expect( + (within(publicationDialog).getByRole('textbox', { + name: '运营海报游戏分类', + }) as HTMLInputElement).value, + ).toBe('非对称对抗'); + expect( + (within(publicationDialog).getByRole('textbox', { + name: '运营海报一句话描述游戏', + }) as HTMLTextAreaElement).value, + ).toBe('找到钥匙,开门逃离马戏团'); + expect(within(publicationDialog).getByLabelText('首图参考')).toBeTruthy(); + expect(screen.getByLabelText('宣发素材生成占位图')).toBeTruthy(); + }); + it('opens a canvas generation frame and composer before creating a generated layer', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index c1efa3dd5..1ec8e5a7b 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -294,6 +294,71 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('restores publication material generator inputs from saved layout', () => { + const dialog: CanvasGenerationDialogState = { + id: 'generation-dialog-publication', + mode: 'publication', + prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', + status: 'idle', + composerOpen: true, + publicationWorkflowId: 'publication-promo-poster', + publicationGameInfo: { + gameName: '马戏团午夜惊魂', + gameCategories: '非对称对抗', + gameDescription: '找到钥匙,开门逃离马戏团', + }, + publicationReferences: [ + { + id: 'publication-reference-1', + label: '首图参考', + src: '/generated-character-drafts/editor/reference.png', + objectKey: 'generated-character-drafts/editor/reference.png', + assetObjectId: 'asset-object-publication-reference', + }, + ], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + generatedLayerId: 'layer-publication', + placeholder: { + x: 100, + y: 120, + width: 1280, + height: 720, + originalWidth: 1280, + originalHeight: 720, + }, + }; + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [dialog], + }), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: 'generation-dialog-publication', + mode: 'publication', + publicationWorkflowId: 'publication-promo-poster', + publicationGameInfo: { + gameName: '马戏团午夜惊魂', + gameCategories: '非对称对抗', + gameDescription: '找到钥匙,开门逃离马戏团', + }, + imageModel: 'gpt-image-2', + generatedLayerId: 'layer-publication', + }); + expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({ + id: 'publication-reference-1', + label: '首图参考', + src: '/generated-character-drafts/editor/reference.png', + objectKey: 'generated-character-drafts/editor/reference.png', + assetObjectId: 'asset-object-publication-reference', + }); + }); + it('serializes generation dialogs beside layers and splits them on load', () => { const layer: CanvasLayer = { id: 'layer-generated', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index cc81f791c..f200cfd7a 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -295,6 +295,15 @@ export function hydrateCanvasGenerationDialog( typeof description === 'string', ) : undefined, + publicationWorkflowId: hydratePublicationWorkflowId( + snapshot.publicationWorkflowId, + ), + publicationGameInfo: hydratePublicationGameInfo( + snapshot.publicationGameInfo, + ), + publicationReferences: hydrateCharacterReferences( + snapshot.publicationReferences, + ), uiDesignSpecReference: hydrateCharacterReference( snapshot.uiDesignSpecReference, ), @@ -742,6 +751,7 @@ function isCanvasGenerationDialogMode( value === 'spec' || value === 'character' || value === 'icon' || + value === 'publication' || value === 'ui-design' || value === 'quick-edit' || value === 'character-animation' || @@ -768,6 +778,30 @@ function isSpecGenerationType( ); } +function hydratePublicationWorkflowId( + value: unknown, +): CanvasGenerationDialogState['publicationWorkflowId'] { + return value === 'publication-cover-image' || + value === 'publication-detail-gallery' || + value === 'publication-promo-poster' + ? value + : undefined; +} + +function hydratePublicationGameInfo( + value: unknown, +): CanvasGenerationDialogState['publicationGameInfo'] { + if (!value || typeof value !== 'object') { + return undefined; + } + const snapshot = value as Record; + return { + gameName: stringOrUndefined(snapshot.gameName) ?? '', + gameCategories: stringOrUndefined(snapshot.gameCategories) ?? '', + gameDescription: stringOrUndefined(snapshot.gameDescription) ?? '', + }; +} + function hydrateSpecFormValues( value: unknown, ): CanvasGenerationDialogState['specValues'] { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 99aabbec2..5a6325ba2 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -277,6 +277,52 @@ describe('ImageCanvasGenerationComposerView', () => { ); }); + it('恢复宣发素材生成卡片的字段和参考图', () => { + renderComposer({ + mode: 'publication', + prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', + status: 'idle', + composerOpen: true, + publicationWorkflowId: 'publication-promo-poster', + publicationGameInfo: { + gameName: '马戏团午夜惊魂', + gameCategories: '非对称对抗', + gameDescription: '找到钥匙,开门逃离马戏团', + }, + publicationReferences: [ + { + id: 'publication-reference-1', + label: '首图参考', + src: '/generated-character-drafts/editor/reference.png', + }, + ], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + }); + + const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' }); + expect( + (within(panel).getByRole('textbox', { + name: '运营海报游戏名', + }) as HTMLInputElement).value, + ).toBe('马戏团午夜惊魂'); + expect( + (within(panel).getByRole('textbox', { + name: '运营海报游戏分类', + }) as HTMLInputElement).value, + ).toBe('非对称对抗'); + expect( + (within(panel).getByRole('textbox', { + name: '运营海报一句话描述游戏', + }) as HTMLTextAreaElement).value, + ).toBe('找到钥匙,开门逃离马戏团'); + expect(within(panel).getByLabelText('首图参考')).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '删除首图参考' }), + ).toBeTruthy(); + }); + it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); const setIsGenerationReferenceMenuOpen = vi.fn();