新增编辑器生成规范、生成角色形象、生成图标素材等功能

新增编辑器生成规范、生成角色形象、生成图标素材等功能
This commit is contained in:
2026-06-16 14:47:13 +08:00
parent 0fd0a06387
commit 7eeff10c67
33 changed files with 8783 additions and 502 deletions

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { resolveEditorImageReferenceDataUrl } from './editorImageReference';
describe('editorImageReference', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('passes through base64 image data urls without fetching', async () => {
vi.spyOn(globalThis, 'fetch');
await expect(
resolveEditorImageReferenceDataUrl('data:image/png;base64,c291cmNl'),
).resolves.toBe('data:image/png;base64,c291cmNl');
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('converts public image paths to base64 image data urls', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/webp',
},
}),
);
await expect(
resolveEditorImageReferenceDataUrl('/creation-type-references/puzzle.webp'),
).resolves.toBe('data:image/webp;base64,aGVsbG8=');
});
});

View File

@@ -0,0 +1,48 @@
import { readAssetBytes } from '../assetReadUrlService';
function normalizeImageContentType(contentType: string | null) {
const mimeType = contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
return mimeType.startsWith('image/') ? mimeType : 'image/png';
}
function encodeBytesAsBase64(bytes: Uint8Array) {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
/**
* 图片画布的生成编辑接口只接收图片 Data URL。
* 画布里既有上传 / 生成图,也有内置 public 参考图和历史 generated 路径;
* 提交给后端前统一读取成 Data URL避免后端误收到普通 URL 后进入 Data URL 校验错误。
*/
export async function resolveEditorImageReferenceDataUrl(
source: string,
signal?: AbortSignal,
) {
const normalizedSource = source.trim();
if (!normalizedSource) {
throw new Error('图片参考图不能为空');
}
if (normalizedSource.startsWith('data:image/')) {
return normalizedSource;
}
if (normalizedSource.startsWith('data:')) {
throw new Error('图片参考图必须是图片 Data URL');
}
const response = await readAssetBytes(normalizedSource, {
signal,
expireSeconds: 300,
});
const mimeType = normalizeImageContentType(response.headers.get('Content-Type'));
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength <= 0) {
throw new Error('图片参考图为空');
}
return `data:${mimeType};base64,${encodeBytesAsBase64(bytes)}`;
}

View File

@@ -9,9 +9,11 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
loadEditorAssetLibrary,
listEditorProjects,
loadEditorAssetLibrary,
loadEditorProject,
loadOrCreateRecentEditorProject,
renameEditorProject,
@@ -117,11 +119,15 @@ describe('editorProjectClient', () => {
projectId: 'editor-project-1',
title: '默认画布',
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
layers: [
{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 },
],
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
layers: [
{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 },
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
@@ -139,7 +145,9 @@ describe('editorProjectClient', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
layers: [
{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 },
],
}),
}),
'保存图片画布工程失败',
@@ -559,6 +567,158 @@ describe('editorProjectClient', () => {
);
});
it('generates icon spritesheets through the dedicated backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 96,
height: 96,
},
],
prompt: '图标素材 prompt',
actualPrompt: '图标素材 prompt',
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-spritesheet-task-1',
});
const result = await generateEditorIconSpritesheet({
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮', '设置按钮'],
});
expect(result.taskId).toBe('icon-spritesheet-task-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/icon-spritesheets/generations',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
}),
}),
'生成图标素材失败',
expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
it('passes spec generation size and kind to the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,spec',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: '生成规范图',
actualPrompt: '生成规范图',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'vector-spec-1',
});
const result = await generateEditorImage({
prompt: '生成规范图',
size: '2048x1152',
kind: 'spec',
model: 'gpt-image-2',
});
expect(result.width).toBe(2048);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/generations',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '生成规范图',
size: '2048x1152',
kind: 'spec',
model: 'gpt-image-2',
}),
}),
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
it('generates editor character animations through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
taskId: 'character-animation-1',
model: 'seedance2.0',
prompt: '生成游戏角色动画\n动作描述\n待机',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
],
frameCount: 32,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
});
const result = await generateEditorCharacterAnimation({
sourceLayerId: 'layer-character',
sourceImageSrc: 'data:image/png;base64,character',
sourceWidth: 1024,
sourceHeight: 1024,
promptText: '待机',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
priceMudPoints: 40,
model: 'seedance2.0',
});
expect(result.taskId).toBe('character-animation-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/character-animations/generations',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceLayerId: 'layer-character',
sourceImageSrc: 'data:image/png;base64,character',
sourceWidth: 1024,
sourceHeight: 1024,
promptText: '待机',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
priceMudPoints: 40,
model: 'seedance2.0',
}),
}),
'生成角色动画失败',
expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
it('edits editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,edited',
@@ -575,6 +735,8 @@ describe('editorProjectClient', () => {
const result = await editEditorImage({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
});
expect(result.taskId).toBe('vector-edit-1');
@@ -586,6 +748,8 @@ describe('editorProjectClient', () => {
body: JSON.stringify({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
}),
}),
'修改图片失败',

View File

@@ -4,6 +4,11 @@ const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
const EDITOR_ASSET_API_BASE = '/api/editor/assets';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
'/api/editor/icon-spritesheets/generations';
const EDITOR_CHARACTER_ANIMATION_GENERATION_API =
'/api/editor/character-animations/generations';
const EDITOR_ICON_SPRITESHEET_MODEL = 'gemini-3.1-flash-image-preview';
const DEFAULT_PROJECT_TITLE = '未命名画布';
const EDITOR_PROJECT_REQUEST_OPTIONS = {
clearAuthOnUnauthorized: false,
@@ -81,15 +86,29 @@ export type EditorAssetLibrarySnapshot = {
export type EditorImageGenerationInput = {
prompt: string;
size?: string;
kind?: 'spec' | 'character' | 'quick-edit';
model?: string;
referenceImageSrcs?: string[];
};
export type EditorIconSpritesheetGenerationInput = {
referenceImageSrc: string;
iconDescriptions: string[];
model?: string;
};
export type EditorImageEditInput = {
prompt: string;
sourceImageSrc: string;
size?: string;
model?: string;
};
export type EditorImageGenerationResult = {
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
sourceType: 'generated';
@@ -100,6 +119,72 @@ export type EditorImageGenerationResult = {
taskId: string;
};
export type EditorIconSpritesheetIconResult = {
name: string;
imageSrc: string;
width: number;
height: number;
};
export type EditorIconSpritesheetGenerationResult = {
spritesheetImageSrc: string;
spritesheetWidth: number;
spritesheetHeight: number;
iconImageSrcs: EditorIconSpritesheetIconResult[];
prompt: string;
actualPrompt?: string | null;
model: string;
provider: string;
taskId: string;
};
export type EditorCharacterAnimationResolution = '480p' | '720p';
export type EditorCharacterAnimationRatio =
| 'same'
| '1:1'
| '4:3'
| '16:9'
| '9:16'
| '3:4';
export type EditorCharacterAnimationFrameCount = 32 | 40 | 48;
export type EditorCharacterAnimationDurationSeconds = 4 | 5 | 6;
export type EditorCharacterAnimationGenerationInput = {
sourceLayerId: string;
sourceImageSrc: string;
sourceWidth: number;
sourceHeight: number;
promptText: string;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;
durationSeconds: EditorCharacterAnimationDurationSeconds;
priceMudPoints: number;
model: 'seedance2.0';
};
export type EditorCharacterAnimationFrameResult = {
frameIndex: number;
imageSrc: string;
width: number;
height: number;
};
export type EditorCharacterAnimationGenerationResult = {
taskId: string;
model: 'seedance2.0';
prompt: string;
previewVideoPath: string;
frames: EditorCharacterAnimationFrameResult[];
frameCount: number;
durationSeconds: number;
fps: number;
priceMudPoints: number;
};
export type EditorProjectSnapshot = {
projectId: string;
title: string;
@@ -194,6 +279,8 @@ type EditorAssetResponse = {
};
type EditorImageGenerationResponse = EditorImageGenerationResult;
type EditorIconSpritesheetGenerationResponse =
EditorIconSpritesheetGenerationResult;
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
return {
@@ -222,10 +309,14 @@ export async function loadRecentEditorProject() {
);
}
export async function createEditorProject(input: EditorProjectCreateInput = {}) {
export async function createEditorProject(
input: EditorProjectCreateInput = {},
) {
const response = await requestJson<EditorProjectResponse>(
EDITOR_PROJECT_API_BASE,
jsonRequest('POST', { title: input.title?.trim() || DEFAULT_PROJECT_TITLE }),
jsonRequest('POST', {
title: input.title?.trim() || DEFAULT_PROJECT_TITLE,
}),
'创建图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
@@ -309,7 +400,10 @@ export async function loadEditorAssetLibrary() {
return response.library;
}
export async function createEditorAssetFolder(label: string, sortOrder?: number) {
export async function createEditorAssetFolder(
label: string,
sortOrder?: number,
) {
const response = await requestJson<EditorAssetFolderResponse>(
`${EDITOR_ASSET_API_BASE}/folders`,
jsonRequest('POST', { label, sortOrder }),
@@ -352,7 +446,10 @@ export async function createEditorAsset(input: EditorAssetCreateInput) {
return response.asset;
}
export async function updateEditorAsset(assetId: string, input: EditorAssetUpdateInput) {
export async function updateEditorAsset(
assetId: string,
input: EditorAssetUpdateInput,
) {
const response = await requestJson<EditorAssetResponse>(
`${EDITOR_ASSET_API_BASE}/${encodeURIComponent(assetId)}`,
jsonRequest('PATCH', input),
@@ -375,7 +472,15 @@ export async function deleteEditorAsset(assetId: string) {
export async function generateEditorImage(input: EditorImageGenerationInput) {
return requestJson<EditorImageGenerationResponse>(
EDITOR_IMAGE_GENERATION_API,
jsonRequest('POST', { prompt: input.prompt }),
jsonRequest('POST', {
prompt: input.prompt,
...(input.size ? { size: input.size } : {}),
...(input.kind ? { kind: input.kind } : {}),
...(input.model ? { model: input.model } : {}),
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
}),
'生成图片失败',
{
...EDITOR_PROJECT_REQUEST_OPTIONS,
@@ -387,12 +492,35 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
);
}
export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
return requestJson<EditorIconSpritesheetGenerationResponse>(
EDITOR_ICON_SPRITESHEET_GENERATION_API,
jsonRequest('POST', {
referenceImageSrc: input.referenceImageSrc,
iconDescriptions: input.iconDescriptions,
model: input.model?.trim() || EDITOR_ICON_SPRITESHEET_MODEL,
}),
'生成图标素材失败',
{
...EDITOR_PROJECT_REQUEST_OPTIONS,
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
},
);
}
export async function editEditorImage(input: EditorImageEditInput) {
return requestJson<EditorImageGenerationResponse>(
EDITOR_IMAGE_EDIT_API,
jsonRequest('POST', {
prompt: input.prompt,
sourceImageSrc: input.sourceImageSrc,
...(input.size ? { size: input.size } : {}),
...(input.model ? { model: input.model } : {}),
}),
'修改图片失败',
{
@@ -404,3 +532,20 @@ export async function editEditorImage(input: EditorImageEditInput) {
},
);
}
export async function generateEditorCharacterAnimation(
input: EditorCharacterAnimationGenerationInput,
) {
return requestJson<EditorCharacterAnimationGenerationResult>(
EDITOR_CHARACTER_ANIMATION_GENERATION_API,
jsonRequest('POST', input),
'生成角色动画失败',
{
...EDITOR_PROJECT_REQUEST_OPTIONS,
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
},
);
}