合并画板素材生成与宣发入口改动
新增 UI 设计图提取素材入口并复用 gpt-image-2 spritesheet 拆分流程。 保留图标生成与 UI 提取的 spritesheet 原图并同步放置拆分素材到画布。 补齐宣发素材演示入口、占位图样式、引用上传与生成提交链路。 调整编辑器生成接口 body 限制和 VectorEngine 请求兼容逻辑。 更新画板音乐生成、项目基线、宣发素材与图片画布相关文档。
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user