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();