合并画板素材生成与宣发入口改动

新增 UI 设计图提取素材入口并复用 gpt-image-2 spritesheet 拆分流程。

保留图标生成与 UI 提取的 spritesheet 原图并同步放置拆分素材到画布。

补齐宣发素材演示入口、占位图样式、引用上传与生成提交链路。

调整编辑器生成接口 body 限制和 VectorEngine 请求兼容逻辑。

更新画板音乐生成、项目基线、宣发素材与图片画布相关文档。
This commit is contained in:
2026-06-19 10:37:22 +08:00
47 changed files with 1948 additions and 115 deletions
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { resolveEditorImageReferenceDataUrl } from './editorImageReference';
import {
compressEditorImageReferenceDataUrlForGeneration,
resolveEditorImageReferenceDataUrl,
} from './editorImageReference';
describe('editorImageReference', () => {
afterEach(() => {
@@ -27,7 +30,17 @@ describe('editorImageReference', () => {
);
await expect(
resolveEditorImageReferenceDataUrl('/creation-type-references/puzzle.webp'),
resolveEditorImageReferenceDataUrl(
'/creation-type-references/puzzle.webp',
),
).resolves.toBe('data:image/webp;base64,aGVsbG8=');
});
it('keeps generation references unchanged when browser canvas is unavailable', async () => {
await expect(
compressEditorImageReferenceDataUrlForGeneration(
'data:image/png;base64,large-reference',
),
).resolves.toBe('data:image/png;base64,large-reference');
});
});
@@ -1,5 +1,15 @@
import { readAssetBytes } from '../assetReadUrlService';
const GENERATION_REFERENCE_MAX_EDGE = 1280;
const GENERATION_REFERENCE_QUALITY = 0.82;
const GENERATION_REFERENCE_MIME_TYPE = 'image/jpeg';
type EditorImageReferenceCompressionOptions = {
maxEdge?: number;
quality?: number;
mimeType?: string;
};
function normalizeImageContentType(contentType: string | null) {
const mimeType = contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
return mimeType.startsWith('image/') ? mimeType : 'image/png';
@@ -9,7 +19,9 @@ 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));
binary += String.fromCharCode(
...bytes.subarray(offset, offset + chunkSize),
);
}
return btoa(binary);
}
@@ -38,7 +50,9 @@ export async function resolveEditorImageReferenceDataUrl(
signal,
expireSeconds: 300,
});
const mimeType = normalizeImageContentType(response.headers.get('Content-Type'));
const mimeType = normalizeImageContentType(
response.headers.get('Content-Type'),
);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength <= 0) {
throw new Error('图片参考图为空');
@@ -46,3 +60,75 @@ export async function resolveEditorImageReferenceDataUrl(
return `data:${mimeType};base64,${encodeBytesAsBase64(bytes)}`;
}
function loadImageDataUrl(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
if (typeof Image === 'undefined') {
reject(new Error('当前环境不支持图片压缩'));
return;
}
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('参考图读取失败'));
image.src = dataUrl;
});
}
export async function compressEditorImageReferenceDataUrlForGeneration(
dataUrl: string,
options: EditorImageReferenceCompressionOptions = {},
) {
if (!dataUrl.startsWith('data:image/') || typeof document === 'undefined') {
return dataUrl;
}
const maxEdge = Math.max(1, options.maxEdge ?? GENERATION_REFERENCE_MAX_EDGE);
const quality = Math.min(
1,
Math.max(0.1, options.quality ?? GENERATION_REFERENCE_QUALITY),
);
const mimeType = options.mimeType ?? GENERATION_REFERENCE_MIME_TYPE;
try {
const image = await loadImageDataUrl(dataUrl);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (sourceWidth <= 0 || sourceHeight <= 0) {
return dataUrl;
}
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext('2d');
if (!context) {
return dataUrl;
}
// 中文注释:参考图只用于模型理解视觉方向,提交前压缩可避免原图 Data URL 撑爆 JSON 请求体。
if (mimeType === 'image/jpeg') {
context.fillStyle = '#fff';
context.fillRect(0, 0, targetWidth, targetHeight);
}
context.drawImage(image, 0, 0, targetWidth, targetHeight);
const compressed = canvas.toDataURL(mimeType, quality);
return compressed.startsWith('data:image/') &&
compressed.length < dataUrl.length
? compressed
: dataUrl;
} catch {
return dataUrl;
}
}
export async function resolveEditorImageReferenceDataUrlForGeneration(
source: string,
signal?: AbortSignal,
) {
const dataUrl = await resolveEditorImageReferenceDataUrl(source, signal);
return compressEditorImageReferenceDataUrlForGeneration(dataUrl);
}
@@ -751,6 +751,47 @@ describe('editorProjectClient', () => {
);
});
it('passes publication material generation kind and size to the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,publication',
width: 720,
height: 1280,
sourceType: 'generated',
prompt: '宣发素材 prompt',
actualPrompt: '宣发素材 prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'vector-publication-1',
});
const result = await generateEditorImage({
prompt: '宣发素材 prompt',
size: '720x1280',
kind: 'publication-material',
referenceImageSrcs: ['data:image/png;base64,ref'],
});
expect(result.height).toBe(1280);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/generations',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '宣发素材 prompt',
size: '720x1280',
kind: 'publication-material',
referenceImageSrcs: ['data:image/png;base64,ref'],
}),
}),
'生成图片失败',
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',
@@ -92,7 +92,12 @@ export type EditorAssetLibrarySnapshot = {
export type EditorImageGenerationInput = {
prompt: string;
size?: string;
kind?: 'spec' | 'character' | 'quick-edit' | 'ui-design';
kind?:
| 'spec'
| 'character'
| 'quick-edit'
| 'ui-design'
| 'publication-material';
model?: string;
aspectRatio?: string;
imageSize?: string;