新增图片画布编辑器
新增 /editor 图片画布入口与 Lovart 风格画布交互 新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF 接入图片生成和修改的 VectorEngine gpt-image-2 后端通道 完善素材库文件夹、重命名、上传删除、图层和元数据交互 补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
251
src/services/image-editor/editorProjectClient.test.ts
Normal file
251
src/services/image-editor/editorProjectClient.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorProject,
|
||||
createEditorProjectResource,
|
||||
editEditorImage,
|
||||
generateEditorImage,
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
} from './editorProjectClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('editorProjectClient', () => {
|
||||
afterEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads the recent project without creating a duplicate when it exists', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await loadOrCreateRecentEditorProject();
|
||||
|
||||
expect(project.projectId).toBe('editor-project-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/recent',
|
||||
{ method: 'GET' },
|
||||
'读取图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a default project when there is no recent project', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({ project: null })
|
||||
.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-created',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await loadOrCreateRecentEditorProject();
|
||||
|
||||
expect(project.projectId).toBe('editor-project-created');
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '未命名画布' }),
|
||||
}),
|
||||
'创建图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves viewport and layer layout through the project API', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await saveEditorProjectLayout('editor-project-1', {
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
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 }],
|
||||
}),
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a resource with upload or generated metadata', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
resource: {
|
||||
resourceId: 'resource-generated-1',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
},
|
||||
});
|
||||
|
||||
await createEditorProjectResource('editor-project-1', {
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1/resources',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
}),
|
||||
'创建图片画布资源失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an explicit project from title input', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-explicit',
|
||||
title: '角色设定板',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await createEditorProject({ title: '角色设定板' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '角色设定板' }),
|
||||
}),
|
||||
'创建图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor images through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,abc',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '一张画布图片',
|
||||
actualPrompt: '一张画布图片',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-task-1',
|
||||
});
|
||||
|
||||
const result = await generateEditorImage({ prompt: '一张画布图片' });
|
||||
|
||||
expect(result.taskId).toBe('vector-task-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: '一张画布图片' }),
|
||||
}),
|
||||
'生成图片失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits editor images through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,edited',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '把画面改成黄昏光线',
|
||||
actualPrompt: '把画面改成黄昏光线',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-edit-1',
|
||||
});
|
||||
|
||||
const result = await editEditorImage({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('vector-edit-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/edits',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
}),
|
||||
}),
|
||||
'修改图片失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
209
src/services/image-editor/editorProjectClient.ts
Normal file
209
src/services/image-editor/editorProjectClient.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||
const EDITOR_PROJECT_REQUEST_OPTIONS = {
|
||||
authImpact: 'local' as const,
|
||||
};
|
||||
|
||||
export type EditorCanvasViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type EditorProjectLayerSnapshot = Record<string, unknown> & {
|
||||
layerId: string;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export type EditorProjectResourceSourceType =
|
||||
| 'uploaded'
|
||||
| 'generated'
|
||||
| 'mock_generated';
|
||||
|
||||
export type EditorProjectResourceSnapshot = {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationInput = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type EditorImageEditInput = {
|
||||
prompt: string;
|
||||
sourceImageSrc: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = {
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
prompt: string;
|
||||
actualPrompt?: string | null;
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type EditorProjectSnapshot = {
|
||||
projectId: string;
|
||||
title: string;
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
resources: EditorProjectResourceSnapshot[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type EditorProjectCreateInput = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type EditorProjectLayoutSaveInput = {
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
};
|
||||
|
||||
export type EditorProjectResourceCreateInput = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
};
|
||||
|
||||
type EditorProjectResponse = {
|
||||
project: EditorProjectSnapshot;
|
||||
};
|
||||
|
||||
type EditorProjectRecentResponse = {
|
||||
project: EditorProjectSnapshot | null;
|
||||
};
|
||||
|
||||
type EditorProjectResourceResponse = {
|
||||
resource: EditorProjectResourceSnapshot;
|
||||
};
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
|
||||
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRecentEditorProject() {
|
||||
return requestJson<EditorProjectRecentResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/recent`,
|
||||
{ method: 'GET' },
|
||||
'读取图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEditorProject(input: EditorProjectCreateInput = {}) {
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
EDITOR_PROJECT_API_BASE,
|
||||
jsonRequest('POST', { title: input.title?.trim() || DEFAULT_PROJECT_TITLE }),
|
||||
'创建图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function loadOrCreateRecentEditorProject() {
|
||||
const response = await loadRecentEditorProject();
|
||||
if (response.project) {
|
||||
return response.project;
|
||||
}
|
||||
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
|
||||
}
|
||||
|
||||
export async function saveEditorProjectLayout(
|
||||
projectId: string,
|
||||
input: EditorProjectLayoutSaveInput,
|
||||
) {
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
|
||||
jsonRequest('PATCH', {
|
||||
viewport: input.viewport,
|
||||
layers: input.layers,
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function createEditorProjectResource(
|
||||
projectId: string,
|
||||
input: EditorProjectResourceCreateInput,
|
||||
) {
|
||||
const response = await requestJson<EditorProjectResourceResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}/resources`,
|
||||
jsonRequest('POST', { ...input }),
|
||||
'创建图片画布资源失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.resource;
|
||||
}
|
||||
|
||||
export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
return requestJson<EditorImageGenerationResponse>(
|
||||
EDITOR_IMAGE_GENERATION_API,
|
||||
jsonRequest('POST', { prompt: input.prompt }),
|
||||
'生成图片失败',
|
||||
{
|
||||
...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,
|
||||
}),
|
||||
'修改图片失败',
|
||||
{
|
||||
...EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user