修复宣发素材生成器刷新恢复

恢复画布布局中的 publication 生成器快照

补齐宣发素材卡片类型、游戏信息和参考图 hydrate

增加模型、面板和编辑器加载链路回归测试

更新图片画布持久化文档和共享踩坑记录
This commit is contained in:
2026-06-19 17:23:34 +08:00
parent 1b1caf13b8
commit aa67e72ec5
6 changed files with 228 additions and 2 deletions
@@ -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。`
@@ -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` 时也固定携带这些参数。
- 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。
@@ -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(<ImageCanvasEditorView />);
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==',
@@ -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',
@@ -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<string, unknown>;
return {
gameName: stringOrUndefined(snapshot.gameName) ?? '',
gameCategories: stringOrUndefined(snapshot.gameCategories) ?? '',
gameDescription: stringOrUndefined(snapshot.gameDescription) ?? '',
};
}
function hydrateSpecFormValues(
value: unknown,
): CanvasGenerationDialogState['specValues'] {
@@ -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();