新增图片画布编辑器

新增 /editor 图片画布入口与 Lovart 风格画布交互

新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF

接入图片生成和修改的 VectorEngine gpt-image-2 后端通道

完善素材库文件夹、重命名、上传删除、图层和元数据交互

补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
2026-06-13 16:22:18 +08:00
parent f8a80cd795
commit 747473024d
53 changed files with 6694 additions and 29 deletions

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

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