修复宣发素材生成器刷新恢复
恢复画布布局中的 publication 生成器快照 补齐宣发素材卡片类型、游戏信息和参考图 hydrate 增加模型、面板和编辑器加载链路回归测试 更新图片画布持久化文档和共享踩坑记录
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user