重构图标规范与图集生成链路

新增图标规范独立队列任务并将参数补全纳入统一计费操作
将图标图集生成和手工拆分迁入独立图标模块并复用公共生图能力
将图标规范引用收紧为项目资源或素材 ID
同步 SpacetimeDB 引用解析契约、External OpenAPI、前端与 Agent 提交链路
补充相关回归测试和长期架构约定
This commit is contained in:
2026-08-06 15:43:10 +08:00
parent e6f0e6248e
commit 3fdbe81bfe
30 changed files with 3042 additions and 2691 deletions
@@ -2701,7 +2701,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'resource-icon-spec',
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
@@ -3171,7 +3171,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
referenceId: 'resource-editor-icon-spec-renamed-1',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
@@ -243,7 +243,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
input: {
playSetting: '休闲消除',
artStyle: '清爽卡通',
referenceImageSrcs: ['data:image/png;base64,ref'],
referenceId: 'resource-spec-ref',
generationInputs: {
fields: [
{ title: '玩法设定', value: '休闲消除' },
@@ -262,6 +262,20 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
assetKind: 'icon-spec',
title: '图标规范 8',
generationInputs: {
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
});
expect(plan.kind === 'icon-spec' ? plan.result.generationInputs : null).toEqual(
@@ -733,7 +747,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
ok: true,
iconDescriptions: ['返回按钮\n\n设置按钮'],
input: {
referenceImageSrc: 'data:image/png;base64,spec',
referenceId: 'resource-icon-spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
model: 'gpt-image-2',
@@ -91,6 +91,25 @@ export function resolveImageReferenceSubmissionSource(reference: {
return reference.objectKey?.trim() || reference.src;
}
export function resolveRegisteredEditorReferenceId(reference: {
resourceId?: string | null;
sourceAssetId?: string | null;
}) {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
!resourceId.startsWith('local-resource-') &&
!resourceId.startsWith('generation-dialog:')
) {
return resourceId;
}
const assetId = reference.sourceAssetId?.trim();
if (assetId) {
return assetId;
}
throw new Error('参考图尚未登记为项目资源或素材,请重新选择或上传后再试');
}
function buildSeedanceVideoReferenceInput(
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
@@ -318,9 +337,9 @@ export function buildImageGenerationSubmissionPlan({
artStyle: specValues.artStyle.trim(),
...(dialog.specReference?.src
? {
referenceImageSrcs: [
resolveImageReferenceSubmissionSource(dialog.specReference),
],
referenceId: resolveRegisteredEditorReferenceId(
dialog.specReference,
),
}
: {}),
generationInputs,
@@ -664,9 +683,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
ok: true,
iconDescriptions,
input: {
referenceImageSrc: resolveImageReferenceSubmissionSource(
dialog.iconSpecReference,
),
referenceId: resolveRegisteredEditorReferenceId(dialog.iconSpecReference),
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
@@ -2667,6 +2667,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-spec',
},
iconDescriptions: ['旧返回', '旧设置'],
placeholder: {
@@ -2687,8 +2688,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
@@ -2754,6 +2754,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-spec',
},
iconDescriptions: [],
placeholder: {
@@ -2779,7 +2780,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
it('submits registered icon spec IDs and keeps objectKey extra references', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
@@ -2806,6 +2807,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
src: 'https://signed.example.test/icon-spec.png?expires=1',
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
assetObjectId: 'assetobj-icon-spec',
resourceId: 'resource-icon-spec',
},
generationReferences: [
{
@@ -2830,8 +2832,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
).not.toHaveBeenCalled();
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc:
'generated-character-drafts/editor/specs/icon-spec.png',
referenceId: 'resource-icon-spec',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/icon-style.png',
],
@@ -81,6 +81,7 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
@@ -1273,11 +1274,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceImageSrc = await resolveEditorGenerationMediaReference(
iconSpecReference,
'image',
projectId,
);
const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
@@ -1290,7 +1287,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
referenceImageSrc,
referenceId,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.generationInputs,
@@ -1943,11 +1940,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const iconSpecInput = submissionPlan.input;
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
@@ -900,7 +900,7 @@ describe('editorProjectClient', () => {
).rejects.toThrow('生成参考图最多允许 5 张');
await expect(
generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: references.slice(0, 5),
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
@@ -935,7 +935,7 @@ describe('editorProjectClient', () => {
);
await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/icon-spec.png',
referenceId: 'editor-resource-icon-spec',
referenceImageSrcs: references,
iconDescriptions: ['返回按钮'],
});
@@ -1118,7 +1118,7 @@ describe('editorProjectClient', () => {
});
const result = await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
assetLabel: '冒险游戏图标',
@@ -1135,7 +1135,7 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
@@ -1164,7 +1164,7 @@ describe('editorProjectClient', () => {
});
await generateEditorIconSpritesheet({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
@@ -1186,7 +1186,7 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
referenceImageSrc: '/generated-images/editor/spec.png',
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
@@ -1452,20 +1452,6 @@ describe('editorProjectClient', () => {
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('rejects excess icon spec references before dispatch', async () => {
await expect(
generateEditorIconSpec({
playSetting: '回合制占点',
artStyle: '低多边形',
referenceImageSrcs: Array.from(
{ length: 6 },
(_, index) => `resource-reference-${index + 1}`,
),
}),
).rejects.toThrow('图标规范参考图最多允许 5 张,当前选择 6 张');
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('submits icon spec business fields without a client prompt or image kind', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,spec',
@@ -1476,7 +1462,7 @@ describe('editorProjectClient', () => {
await generateEditorIconSpec({
playSetting: '回合制占点',
artStyle: '低多边形',
referenceImageSrcs: ['resource-reference-1'],
referenceId: 'resource-reference-1',
projectId: 'editor-project-1',
generationInputs: {
fields: [],
@@ -1498,7 +1484,7 @@ describe('editorProjectClient', () => {
expect(body).toEqual({
playSetting: '回合制占点',
artStyle: '低多边形',
referenceImageSrcs: ['resource-reference-1'],
referenceId: 'resource-reference-1',
projectId: 'editor-project-1',
generationInputs: {
fields: [],
@@ -258,7 +258,7 @@ export type EditorImageGenerationInput = {
export type EditorIconSpecGenerationInput = {
playSetting: string;
artStyle: string;
referenceImageSrcs?: string[];
referenceId?: string;
projectId?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
@@ -281,7 +281,7 @@ export type EditorCanvasGenerationCompletionInput = {
};
export type EditorIconSpritesheetGenerationInput = {
referenceImageSrc: string;
referenceId: string;
referenceImageSrcs?: string[];
iconDescriptions: string[];
model?: string;
@@ -1105,20 +1105,12 @@ export async function generateEditorIconSpec(
input.artStyle,
'美术风格',
);
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标规范参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
EDITOR_IMAGE_REFERENCE_LIMIT,
'图标规范参考图',
);
return requestJson<EditorImageGenerationResponse>(
EDITOR_ICON_SPEC_GENERATION_API,
jsonRequest('POST', {
playSetting,
artStyle,
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
...(input.referenceId ? { referenceId: input.referenceId } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
@@ -1143,7 +1135,6 @@ export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范');
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
@@ -1156,7 +1147,7 @@ export async function generateEditorIconSpritesheet(
return requestJson<EditorIconSpritesheetGenerationResponse>(
EDITOR_ICON_SPRITESHEET_GENERATION_API,
jsonRequest('POST', {
referenceImageSrc: input.referenceImageSrc,
referenceId: input.referenceId,
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),