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

新增编辑器生成规范、生成角色形象、生成图标素材等功能
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

@@ -1,10 +1,11 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
type PlatformFloatingMenuProps = {
children: ReactNode;
className?: string;
label?: string;
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
style?: CSSProperties;
};
type PlatformFloatingMenuItemProps = Omit<
@@ -24,6 +25,7 @@ export function PlatformFloatingMenu({
className,
label,
placement = 'top-end',
style,
}: PlatformFloatingMenuProps) {
return (
<div
@@ -36,6 +38,7 @@ export function PlatformFloatingMenu({
.join(' ')}
role="menu"
aria-label={label}
style={style}
>
{children}
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,
},
},
);
}