新增编辑器生成规范、生成角色形象、生成图标素材等功能
新增编辑器生成规范、生成角色形象、生成图标素材等功能
This commit is contained in:
33
src/services/image-editor/editorImageReference.test.ts
Normal file
33
src/services/image-editor/editorImageReference.test.ts
Normal 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=');
|
||||
});
|
||||
});
|
||||
48
src/services/image-editor/editorImageReference.ts
Normal file
48
src/services/image-editor/editorImageReference.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
'修改图片失败',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user