修复画布底部上传入画布尺寸
底部工具栏上传图片后直接创建画布图层 上传图片在创建素材和图层前读取原始分辨率 补充上传尺寸解析与素材库集成回归测试 同步图片画布上传语义文档和共享决策记录
This commit is contained in:
@@ -2499,6 +2499,14 @@
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "hydrates canvas images from Resolution instead of saved Size|opens generated image info from the corner button and creates a real right-side edit result|shows image resolution on hover"`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-19 图片画布上传入口按来源区分入画布语义
|
||||
|
||||
- 背景:底部工具栏上传入口选择文件后只进入素材库,未创建画布图层;同时上传图层和素材记录先使用固定兜底尺寸,浏览器异步读到图片原始尺寸前已经把错误尺寸持久化。
|
||||
- 决策:底部工具栏上传是“上传到画布”入口,选中文件后写入默认素材文件夹并立即在当前画布视口中心创建图层;素材栏文件夹内上传仍只写入目标素材文件夹。上传图片必须在创建占位素材、画布图层和账号级素材记录前解析原图 Resolution,PNG/JPEG/WebP/GIF/BMP 先读文件头,无法解析时才回退浏览器解码或上传兜底尺寸。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasUploadWorkflow.ts`、`src/components/image-editor/ImageCanvasFileModel.ts`、`src/components/image-editor/ImageCanvasUploadModel.ts` 和图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasFileModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx -t "bottom toolbar uploads|multiple files as account-level assets"`、`npm run typecheck`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-17 图片画布底部生成视频接入 Lovart 面板
|
||||
|
||||
- 背景:编辑器画板底部工具栏需要新增 `生成视频`,并和现有 Lovart 式生成类面板、泥点展示、占位图和画布结果图层保持一致。
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
|
||||
- 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入默认素材文件夹并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。
|
||||
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。
|
||||
|
||||
@@ -69,6 +69,45 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
function createSizedPngFile(name: string, width: number, height: number) {
|
||||
const bytes = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
(width >> 24) & 0xff,
|
||||
(width >> 16) & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
(height >> 24) & 0xff,
|
||||
(height >> 16) & 0xff,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
return new File([bytes], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView asset library integration', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
generateEditorImageMock,
|
||||
@@ -380,6 +419,33 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds bottom toolbar uploads to the canvas using the image resolution', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '上传工具' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传媒体文件'),
|
||||
createSizedPngFile('底栏上传.png', 1536, 1024),
|
||||
);
|
||||
|
||||
expect(await screen.findByAltText('画布图片:底栏上传.png')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '底栏上传.png',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const layerButton = screen.getByRole('button', {
|
||||
name: '选择底栏上传.png',
|
||||
});
|
||||
expect(layerButton.getAttribute('style')).toContain('width: 1536px');
|
||||
expect(layerButton.getAttribute('style')).toContain('height: 1024px');
|
||||
});
|
||||
|
||||
it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => {
|
||||
const createdMediaElements: HTMLMediaElement[] = [];
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
@@ -920,7 +920,7 @@ export function ImageCanvasEditorView() {
|
||||
const switchTool = (tool: CanvasTool) => {
|
||||
clearActiveInteraction();
|
||||
if (tool === 'upload') {
|
||||
requestUpload('asset');
|
||||
requestUpload('asset', { addToCanvas: true });
|
||||
return;
|
||||
}
|
||||
if (switchGenerationTool(tool)) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { probeImageFileDimensions } from './ImageCanvasFileModel';
|
||||
|
||||
function makeFile(bytes: number[], name: string, type: string) {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
function uint16be(value: number) {
|
||||
return [(value >> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
|
||||
function uint32be(value: number) {
|
||||
return [
|
||||
(value >> 24) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
function uint16le(value: number) {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function uint24le(value: number) {
|
||||
return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff];
|
||||
}
|
||||
|
||||
function uint32le(value: number) {
|
||||
return [
|
||||
value & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 24) & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
describe('ImageCanvasFileModel', () => {
|
||||
it('probes common upload image dimensions from file headers', async () => {
|
||||
const png = makeFile(
|
||||
[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
...uint32be(13),
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
...uint32be(1536),
|
||||
...uint32be(1024),
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
'画布.png',
|
||||
'image/png',
|
||||
);
|
||||
const jpeg = makeFile(
|
||||
[
|
||||
0xff, 0xd8,
|
||||
0xff, 0xe0,
|
||||
...uint16be(16),
|
||||
0x4a, 0x46, 0x49, 0x46,
|
||||
0, 1, 1, 0, 0, 1, 0, 1, 0, 0,
|
||||
0xff, 0xc0,
|
||||
...uint16be(17),
|
||||
8,
|
||||
...uint16be(720),
|
||||
...uint16be(1280),
|
||||
3,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
],
|
||||
'画布.jpg',
|
||||
'image/jpeg',
|
||||
);
|
||||
const gif = makeFile(
|
||||
[
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
...uint16le(640),
|
||||
...uint16le(480),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
'画布.gif',
|
||||
'image/gif',
|
||||
);
|
||||
const webp = makeFile(
|
||||
[
|
||||
0x52, 0x49, 0x46, 0x46,
|
||||
...uint32le(22),
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58,
|
||||
...uint32le(10),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...uint24le(799),
|
||||
...uint24le(599),
|
||||
],
|
||||
'画布.webp',
|
||||
'image/webp',
|
||||
);
|
||||
const bmp = makeFile(
|
||||
[
|
||||
0x42, 0x4d,
|
||||
...uint32le(54),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...uint32le(54),
|
||||
...uint32le(40),
|
||||
...uint32le(300),
|
||||
...uint32le(200),
|
||||
],
|
||||
'画布.bmp',
|
||||
'image/bmp',
|
||||
);
|
||||
|
||||
await expect(probeImageFileDimensions(png)).resolves.toEqual({
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
});
|
||||
await expect(probeImageFileDimensions(jpeg)).resolves.toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
await expect(probeImageFileDimensions(gif)).resolves.toEqual({
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
await expect(probeImageFileDimensions(webp)).resolves.toEqual({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
await expect(probeImageFileDimensions(bmp)).resolves.toEqual({
|
||||
width: 300,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,296 @@ export function readImageFileAsDataUrl(file: File) {
|
||||
return readFileAsDataUrl(file);
|
||||
}
|
||||
|
||||
function readBlobAsArrayBuffer(blob: Blob) {
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
reject(new Error('文件读取失败'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('文件读取失败'));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export type ImageFileDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function readUint24LittleEndian(bytes: Uint8Array, offset: number) {
|
||||
return (
|
||||
byteAt(bytes, offset) +
|
||||
(byteAt(bytes, offset + 1) << 8) +
|
||||
(byteAt(bytes, offset + 2) << 16)
|
||||
);
|
||||
}
|
||||
|
||||
function byteAt(bytes: Uint8Array, offset: number) {
|
||||
return bytes[offset] ?? 0;
|
||||
}
|
||||
|
||||
// 中文注释:上传素材需要在创建画布图层和持久化素材前拿到原图 Resolution,避免先写入固定兜底尺寸。
|
||||
function parseImageDimensionsFromHeader(
|
||||
buffer: ArrayBuffer,
|
||||
): ImageFileDimensions | null {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
byteAt(bytes, 0) === 0x89 &&
|
||||
byteAt(bytes, 1) === 0x50 &&
|
||||
byteAt(bytes, 2) === 0x4e &&
|
||||
byteAt(bytes, 3) === 0x47
|
||||
) {
|
||||
return {
|
||||
width: view.getUint32(16, false),
|
||||
height: view.getUint32(20, false),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 10 &&
|
||||
byteAt(bytes, 0) === 0x47 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46
|
||||
) {
|
||||
return {
|
||||
width: view.getUint16(6, true),
|
||||
height: view.getUint16(8, true),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 26 &&
|
||||
byteAt(bytes, 0) === 0x42 &&
|
||||
byteAt(bytes, 1) === 0x4d
|
||||
) {
|
||||
return {
|
||||
width: Math.abs(view.getInt32(18, true)),
|
||||
height: Math.abs(view.getInt32(22, true)),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 30 &&
|
||||
byteAt(bytes, 0) === 0x52 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46 &&
|
||||
byteAt(bytes, 3) === 0x46 &&
|
||||
byteAt(bytes, 8) === 0x57 &&
|
||||
byteAt(bytes, 9) === 0x45 &&
|
||||
byteAt(bytes, 10) === 0x42 &&
|
||||
byteAt(bytes, 11) === 0x50
|
||||
) {
|
||||
const chunkType = String.fromCharCode(
|
||||
byteAt(bytes, 12),
|
||||
byteAt(bytes, 13),
|
||||
byteAt(bytes, 14),
|
||||
byteAt(bytes, 15),
|
||||
);
|
||||
const chunkDataOffset = 20;
|
||||
if (chunkType === 'VP8X' && bytes.length >= 30) {
|
||||
return {
|
||||
width: readUint24LittleEndian(bytes, 24) + 1,
|
||||
height: readUint24LittleEndian(bytes, 27) + 1,
|
||||
};
|
||||
}
|
||||
if (
|
||||
chunkType === 'VP8 ' &&
|
||||
bytes.length >= chunkDataOffset + 10 &&
|
||||
byteAt(bytes, chunkDataOffset + 3) === 0x9d &&
|
||||
byteAt(bytes, chunkDataOffset + 4) === 0x01 &&
|
||||
byteAt(bytes, chunkDataOffset + 5) === 0x2a
|
||||
) {
|
||||
return {
|
||||
width: view.getUint16(chunkDataOffset + 6, true) & 0x3fff,
|
||||
height: view.getUint16(chunkDataOffset + 8, true) & 0x3fff,
|
||||
};
|
||||
}
|
||||
if (
|
||||
chunkType === 'VP8L' &&
|
||||
bytes.length >= chunkDataOffset + 5 &&
|
||||
byteAt(bytes, chunkDataOffset) === 0x2f
|
||||
) {
|
||||
const packed =
|
||||
byteAt(bytes, chunkDataOffset + 1) |
|
||||
(byteAt(bytes, chunkDataOffset + 2) << 8) |
|
||||
(byteAt(bytes, chunkDataOffset + 3) << 16) |
|
||||
(byteAt(bytes, chunkDataOffset + 4) << 24);
|
||||
return {
|
||||
width: (packed & 0x3fff) + 1,
|
||||
height: ((packed >> 14) & 0x3fff) + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
byteAt(bytes, 0) === 0xff &&
|
||||
byteAt(bytes, 1) === 0xd8
|
||||
) {
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (byteAt(bytes, offset) !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = byteAt(bytes, offset + 1);
|
||||
if (marker === 0xd8 || marker === 0xd9) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
const segmentLength = view.getUint16(offset + 2, false);
|
||||
if (segmentLength < 2 || offset + 2 + segmentLength > bytes.length) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(marker >= 0xc0 && marker <= 0xc3) ||
|
||||
(marker >= 0xc5 && marker <= 0xc7) ||
|
||||
(marker >= 0xc9 && marker <= 0xcb) ||
|
||||
(marker >= 0xcd && marker <= 0xcf)
|
||||
) {
|
||||
return {
|
||||
height: view.getUint16(offset + 5, false),
|
||||
width: view.getUint16(offset + 7, false),
|
||||
};
|
||||
}
|
||||
offset += 2 + segmentLength;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const HEADER_DIMENSION_IMAGE_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/x-ms-bmp',
|
||||
]);
|
||||
|
||||
function hasSupportedImageHeaderSignature(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
return (
|
||||
(bytes.length >= 4 &&
|
||||
byteAt(bytes, 0) === 0x89 &&
|
||||
byteAt(bytes, 1) === 0x50 &&
|
||||
byteAt(bytes, 2) === 0x4e &&
|
||||
byteAt(bytes, 3) === 0x47) ||
|
||||
(bytes.length >= 3 &&
|
||||
byteAt(bytes, 0) === 0x47 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46) ||
|
||||
(bytes.length >= 2 &&
|
||||
byteAt(bytes, 0) === 0x42 &&
|
||||
byteAt(bytes, 1) === 0x4d) ||
|
||||
(bytes.length >= 12 &&
|
||||
byteAt(bytes, 0) === 0x52 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46 &&
|
||||
byteAt(bytes, 3) === 0x46 &&
|
||||
byteAt(bytes, 8) === 0x57 &&
|
||||
byteAt(bytes, 9) === 0x45 &&
|
||||
byteAt(bytes, 10) === 0x42 &&
|
||||
byteAt(bytes, 11) === 0x50) ||
|
||||
(bytes.length >= 2 &&
|
||||
byteAt(bytes, 0) === 0xff &&
|
||||
byteAt(bytes, 1) === 0xd8)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageFileDimensions(
|
||||
dimensions: ImageFileDimensions | null,
|
||||
) {
|
||||
if (
|
||||
!dimensions ||
|
||||
dimensions.width <= 0 ||
|
||||
dimensions.height <= 0 ||
|
||||
!Number.isFinite(dimensions.width) ||
|
||||
!Number.isFinite(dimensions.height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: Math.round(dimensions.width),
|
||||
height: Math.round(dimensions.height),
|
||||
};
|
||||
}
|
||||
|
||||
async function probeImageDimensionsWithBrowserDecode(file: File) {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const dimensions = { width: bitmap.width, height: bitmap.height };
|
||||
bitmap.close();
|
||||
return normalizeImageFileDimensions(dimensions);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof Image === 'undefined' ||
|
||||
typeof URL === 'undefined' ||
|
||||
typeof URL.createObjectURL !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return new Promise<ImageFileDimensions | null>((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
if (typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
const finish = (dimensions: ImageFileDimensions | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve(dimensions);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(null), 180);
|
||||
image.onload = () => {
|
||||
finish(
|
||||
normalizeImageFileDimensions({
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
}),
|
||||
);
|
||||
};
|
||||
image.onerror = () => {
|
||||
finish(null);
|
||||
};
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeImageFileDimensions(file: File) {
|
||||
const headerBuffer = await readBlobAsArrayBuffer(file.slice(0, 65_536));
|
||||
const headerDimensions = normalizeImageFileDimensions(
|
||||
parseImageDimensionsFromHeader(headerBuffer),
|
||||
);
|
||||
if (headerDimensions) {
|
||||
return headerDimensions;
|
||||
}
|
||||
if (
|
||||
HEADER_DIMENSION_IMAGE_TYPES.has(file.type.toLowerCase()) &&
|
||||
!hasSupportedImageHeaderSignature(headerBuffer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return probeImageDimensionsWithBrowserDecode(file);
|
||||
}
|
||||
|
||||
export function probeMediaDurationSeconds(
|
||||
file: File,
|
||||
mediaType: 'video' | 'audio',
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('ImageCanvasUploadModel', () => {
|
||||
fileName: 'hero.png',
|
||||
folderId: 'characters',
|
||||
}),
|
||||
).toEqual({
|
||||
).toMatchObject({
|
||||
id: 'upload-7',
|
||||
label: 'hero.png',
|
||||
src: '',
|
||||
@@ -128,6 +128,18 @@ describe('ImageCanvasUploadModel', () => {
|
||||
uploadProgress: 8,
|
||||
uploadMessage: '准备上传',
|
||||
});
|
||||
expect(
|
||||
createUploadingAssetPlaceholder({
|
||||
uploadIndex: 8,
|
||||
fileName: 'hero.png',
|
||||
folderId: 'characters',
|
||||
imageDimensions: { width: 1536, height: 1024 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'upload-8',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the target upload folder without changing other folders', () => {
|
||||
@@ -163,7 +175,7 @@ describe('ImageCanvasUploadModel', () => {
|
||||
id: 'upload-1',
|
||||
src: 'data:image/png;base64,read',
|
||||
uploadProgress: 42,
|
||||
uploadMessage: '读取图片',
|
||||
uploadMessage: '读取素材',
|
||||
},
|
||||
{ id: 'upload-other', label: '其他图片.png' },
|
||||
]);
|
||||
@@ -228,7 +240,7 @@ describe('ImageCanvasUploadModel', () => {
|
||||
assetObjectId: 'asset-object',
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
).toMatchObject([
|
||||
{
|
||||
id: 'asset-1',
|
||||
label: '角色.png',
|
||||
@@ -431,6 +443,27 @@ describe('ImageCanvasUploadModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates upload canvas layers with measured image dimensions', () => {
|
||||
const layer = createUploadCanvasLayer({
|
||||
uploadIndex: 1,
|
||||
fileName: '画布素材.png',
|
||||
imageSrc: 'data:image/png;base64,canvas',
|
||||
imageDimensions: { width: 1536, height: 1024 },
|
||||
canvasPoint: { x: 110, y: 120 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
x: -718,
|
||||
y: -462,
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses viewport center fallback for invalid upload points', () => {
|
||||
const layer = createUploadCanvasLayer({
|
||||
uploadIndex: 2,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
type CanvasPoint = { x: number; y: number };
|
||||
type UploadImageDimensions = { width: number; height: number } | null;
|
||||
type PersistedUploadAsset = {
|
||||
assetId: string;
|
||||
folderId: string;
|
||||
@@ -280,22 +281,32 @@ export function createUploadingAssetPlaceholder({
|
||||
fileName,
|
||||
folderId,
|
||||
mediaType = 'image',
|
||||
imageDimensions,
|
||||
durationSeconds,
|
||||
}: {
|
||||
uploadIndex: number;
|
||||
fileName?: string;
|
||||
folderId: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
imageDimensions?: UploadImageDimensions;
|
||||
durationSeconds?: number;
|
||||
}): EditorAsset {
|
||||
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
||||
const size =
|
||||
mediaType === 'image' && imageDimensions
|
||||
? resolveLayerResolutionSize(
|
||||
imageDimensions.width,
|
||||
imageDimensions.height,
|
||||
fallbackSize,
|
||||
)
|
||||
: fallbackSize;
|
||||
return {
|
||||
id: `upload-${uploadIndex}`,
|
||||
label: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'),
|
||||
src: '',
|
||||
mediaType,
|
||||
width: fallbackSize.width,
|
||||
height: fallbackSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
folderId,
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
@@ -504,6 +515,7 @@ export function createUploadCanvasLayer({
|
||||
fileName,
|
||||
imageSrc,
|
||||
mediaType = 'image',
|
||||
imageDimensions,
|
||||
durationSeconds,
|
||||
canvasPoint,
|
||||
canvasSize,
|
||||
@@ -513,12 +525,21 @@ export function createUploadCanvasLayer({
|
||||
fileName?: string;
|
||||
imageSrc: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
imageDimensions?: UploadImageDimensions;
|
||||
durationSeconds?: number;
|
||||
canvasPoint?: CanvasPoint;
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
}): CanvasLayer {
|
||||
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
||||
const layerSize =
|
||||
mediaType === 'image' && imageDimensions
|
||||
? resolveLayerResolutionSize(
|
||||
imageDimensions.width,
|
||||
imageDimensions.height,
|
||||
fallbackSize,
|
||||
)
|
||||
: fallbackSize;
|
||||
const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize });
|
||||
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
||||
const worldCenterX = (screenPoint.x - viewport.x) / safeScale;
|
||||
@@ -535,12 +556,12 @@ export function createUploadCanvasLayer({
|
||||
: mediaType === 'audio'
|
||||
? 'sound-effect'
|
||||
: undefined,
|
||||
x: worldCenterX - fallbackSize.width / 2,
|
||||
y: worldCenterY - fallbackSize.height / 2,
|
||||
width: fallbackSize.width,
|
||||
height: fallbackSize.height,
|
||||
originalWidth: fallbackSize.width,
|
||||
originalHeight: fallbackSize.height,
|
||||
x: worldCenterX - layerSize.width / 2,
|
||||
y: worldCenterY - layerSize.height / 2,
|
||||
width: layerSize.width,
|
||||
height: layerSize.height,
|
||||
originalWidth: layerSize.width,
|
||||
originalHeight: layerSize.height,
|
||||
zIndex: uploadIndex + 10,
|
||||
sourceType: 'uploaded',
|
||||
sourceAssetId: `upload-${uploadIndex}`,
|
||||
|
||||
@@ -59,6 +59,45 @@ function createTestFile(name = '上传素材.png') {
|
||||
return new File(['image'], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
function createSizedPngFile(name: string, width: number, height: number) {
|
||||
const bytes = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
(width >> 24) & 0xff,
|
||||
(width >> 16) & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
(height >> 24) & 0xff,
|
||||
(height >> 16) & 0xff,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
return new File([bytes], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -126,7 +165,7 @@ function UploadWorkflowHarness({
|
||||
{assets
|
||||
.map(
|
||||
(asset) =>
|
||||
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}`,
|
||||
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
@@ -142,7 +181,7 @@ function UploadWorkflowHarness({
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}`,
|
||||
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
@@ -173,6 +212,20 @@ function UploadWorkflowHarness({
|
||||
>
|
||||
上传到画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.addUploadedFiles(
|
||||
[createSizedPngFile('原始尺寸素材.png', 1536, 1024)],
|
||||
{
|
||||
addToCanvas: true,
|
||||
canvasPoint: { x: 110, y: 120 },
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
上传原始尺寸到画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -445,6 +498,30 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the uploaded image resolution before creating the canvas layer and persisted asset', async () => {
|
||||
render(<UploadWorkflowHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '上传原始尺寸到画布' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '原始尺寸素材.png',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'persisted-原始尺寸素材.png:原始尺寸素材.png:project:ready:-:1536x1024',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:原始尺寸素材.png:persisted-原始尺寸素材.png:-718:-462:1536x1024',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => {
|
||||
const openEditorLoginModal = vi.fn();
|
||||
createEditorAssetMock.mockRejectedValueOnce(
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isSeedanceReferenceAudioFile,
|
||||
isSeedanceReferenceImageFile,
|
||||
isSeedanceReferenceVideoFile,
|
||||
probeImageFileDimensions,
|
||||
probeMediaDurationSeconds,
|
||||
readFileAsDataUrl,
|
||||
readImageFileAsDataUrl,
|
||||
@@ -56,6 +57,10 @@ type UploadFilesOptions = {
|
||||
addToCanvas?: boolean;
|
||||
};
|
||||
|
||||
type UploadRequestOptions = {
|
||||
addToCanvas?: boolean;
|
||||
};
|
||||
|
||||
type UploadAssetFileOptions = UploadFilesOptions & {
|
||||
uploadIndex: number;
|
||||
};
|
||||
@@ -105,6 +110,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
|
||||
const uploadTargetRef = useRef<UploadTarget>('asset');
|
||||
const uploadRequestOptionsRef = useRef<UploadRequestOptions>({});
|
||||
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
|
||||
|
||||
canAccessProtectedDataRef.current = canAccessProtectedData;
|
||||
@@ -626,11 +632,16 @@ export function useImageCanvasUploadWorkflow({
|
||||
activeUploadFolderId,
|
||||
});
|
||||
const uploadIndex = options.uploadIndex;
|
||||
let imageDimensions: { width: number; height: number } | null = null;
|
||||
if (mediaType === 'image') {
|
||||
imageDimensions = await probeImageFileDimensions(file).catch(() => null);
|
||||
}
|
||||
const uploadedAsset = createUploadingAssetPlaceholder({
|
||||
uploadIndex,
|
||||
fileName: file.name,
|
||||
folderId: uploadFolderId,
|
||||
mediaType,
|
||||
imageDimensions,
|
||||
});
|
||||
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
@@ -679,6 +690,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
fileName: file.name,
|
||||
imageSrc,
|
||||
mediaType,
|
||||
imageDimensions,
|
||||
durationSeconds,
|
||||
canvasPoint: options.canvasPoint,
|
||||
canvasSize,
|
||||
@@ -823,20 +835,21 @@ export function useImageCanvasUploadWorkflow({
|
||||
);
|
||||
|
||||
const openUploadPicker = useCallback(
|
||||
(target: UploadTarget) => {
|
||||
(target: UploadTarget, options: UploadRequestOptions = {}) => {
|
||||
setUploadTarget(target);
|
||||
uploadRequestOptionsRef.current = options;
|
||||
uploadInputRef.current?.click();
|
||||
},
|
||||
[setUploadTarget],
|
||||
);
|
||||
|
||||
const requestUpload = useCallback(
|
||||
(target: UploadTarget = 'asset') => {
|
||||
(target: UploadTarget = 'asset', options: UploadRequestOptions = {}) => {
|
||||
if (target === 'asset' && !canAccessProtectedDataRef.current) {
|
||||
openEditorLoginModal();
|
||||
return;
|
||||
}
|
||||
openUploadPicker(target);
|
||||
openUploadPicker(target, options);
|
||||
},
|
||||
[openEditorLoginModal, openUploadPicker],
|
||||
);
|
||||
@@ -869,9 +882,14 @@ export function useImageCanvasUploadWorkflow({
|
||||
} else if (currentUploadTarget === 'ui-design-icon-spec') {
|
||||
void addUiDesignSpecReferenceFiles(files);
|
||||
} else {
|
||||
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
|
||||
const requestOptions = uploadRequestOptionsRef.current;
|
||||
addUploadedFiles(files, {
|
||||
addToCanvas:
|
||||
requestOptions.addToCanvas ?? activeTool === 'upload',
|
||||
});
|
||||
}
|
||||
}
|
||||
uploadRequestOptionsRef.current = {};
|
||||
setUploadTarget('asset');
|
||||
event.currentTarget.value = '';
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user