Files
Genarrative/src/components/image-editor/ImageCanvasFileModel.ts
T
suzmii ae5c2d8821 收紧角色外观参考图容量门禁
限制视频模式角色外观图最多九张,并在前端追加、恢复和提交链路统一收口。

在入队归一化和 durable worker 执行前限制单图、累计字节、解码边长与总像素。

补充九/十张、三十兆字节、累计大小和压缩炸弹像素边界回归。

同步更新 Spine 技术方案与团队决策记录。
2026-08-17 15:01:17 +08:00

483 lines
13 KiB
TypeScript

export function isImageFile(file: File) {
return file.type.startsWith('image/');
}
export const EDITOR_ASSET_UPLOAD_ACCEPT =
'image/*,.mp3,.mp4,audio/mpeg,audio/mp3,video/mp4';
export const SEEDANCE_REFERENCE_ACCEPT = {
image:
'.jpg,.jpeg,.png,.webp,.bmp,.tiff,.tif,.gif,.heic,.heif,image/jpeg,image/png,image/webp,image/bmp,image/tiff,image/gif,image/heic,image/heif',
video: '.mp4,.mov,video/mp4,video/quicktime',
audio: '.wav,.mp3,audio/wav,audio/x-wav,audio/mpeg,audio/mp3',
} as const;
export function getEditorUploadAccept(target: string) {
if (target === 'video-reference-video') {
return SEEDANCE_REFERENCE_ACCEPT.video;
}
if (target === 'video-reference-audio') {
return SEEDANCE_REFERENCE_ACCEPT.audio;
}
if (target === 'video-reference-image') {
return SEEDANCE_REFERENCE_ACCEPT.image;
}
return EDITOR_ASSET_UPLOAD_ACCEPT;
}
export const CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT = 9;
export const CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
imageBytes: 30 * 1024 * 1024,
videoBytes: 50 * 1024 * 1024,
audioBytes: 15 * 1024 * 1024,
mediaMinDurationSeconds: 2,
mediaMaxDurationSeconds: 15,
mediaTotalDurationSeconds: 15,
} as const;
const SEEDANCE_IMAGE_EXTENSIONS = new Set([
'jpg',
'jpeg',
'png',
'webp',
'bmp',
'tiff',
'tif',
'gif',
'heic',
'heif',
]);
const SEEDANCE_VIDEO_EXTENSIONS = new Set(['mp4', 'mov']);
const SEEDANCE_AUDIO_EXTENSIONS = new Set(['wav', 'mp3']);
const EDITOR_ASSET_VIDEO_EXTENSIONS = new Set(['mp4']);
const EDITOR_ASSET_AUDIO_EXTENSIONS = new Set(['mp3']);
function getFileExtension(file: File) {
return file.name.split('.').pop()?.toLowerCase() ?? '';
}
function hasAllowedExtension(file: File, extensions: Set<string>) {
return extensions.has(getFileExtension(file));
}
export function isSeedanceReferenceImageFile(file: File) {
return (
(file.type.startsWith('image/') || file.type === '') &&
hasAllowedExtension(file, SEEDANCE_IMAGE_EXTENSIONS)
);
}
export function isSeedanceReferenceVideoFile(file: File) {
return (
(file.type === 'video/mp4' ||
file.type === 'video/quicktime' ||
file.type === '') &&
hasAllowedExtension(file, SEEDANCE_VIDEO_EXTENSIONS)
);
}
export function isSeedanceReferenceAudioFile(file: File) {
return (
(file.type === 'audio/wav' ||
file.type === 'audio/x-wav' ||
file.type === 'audio/mpeg' ||
file.type === 'audio/mp3' ||
file.type === '') &&
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
);
}
export function isEditorAssetVideoFile(file: File) {
return (
(file.type === 'video/mp4' || file.type === '') &&
hasAllowedExtension(file, EDITOR_ASSET_VIDEO_EXTENSIONS)
);
}
export function isEditorAssetAudioFile(file: File) {
return (
(file.type === 'audio/mpeg' || file.type === 'audio/mp3' || file.type === '') &&
hasAllowedExtension(file, EDITOR_ASSET_AUDIO_EXTENSIONS)
);
}
export function resolveEditorAssetUploadMediaType(file: File) {
if (isEditorAssetVideoFile(file)) {
return 'video' as const;
}
if (isEditorAssetAudioFile(file)) {
return 'audio' as const;
}
if (isImageFile(file)) {
return 'image' as const;
}
return null;
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('文件读取失败'));
};
reader.onerror = () => reject(reader.error ?? new Error('文件读取失败'));
reader.readAsDataURL(file);
});
}
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',
) {
return probeMediaFileMetadata(file, mediaType).then(
(metadata) => metadata.durationSeconds,
);
}
export function probeMediaFileMetadata(
file: File,
mediaType: 'video' | 'audio',
) {
return new Promise<{
durationSeconds: number;
dimensions: ImageFileDimensions | null;
}>((resolve, reject) => {
if (
typeof document === 'undefined' ||
typeof URL === 'undefined' ||
typeof URL.createObjectURL !== 'function'
) {
resolve({ durationSeconds: Number.NaN, dimensions: null });
return;
}
const element =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
const objectUrl = URL.createObjectURL(file);
const cleanup = () => {
URL.revokeObjectURL(objectUrl);
element.removeAttribute('src');
element.load();
};
element.preload = 'metadata';
element.onloadedmetadata = () => {
const dimensions =
mediaType === 'video'
? normalizeImageFileDimensions({
width: (element as HTMLVideoElement).videoWidth,
height: (element as HTMLVideoElement).videoHeight,
})
: null;
const durationSeconds = element.duration;
cleanup();
resolve({ durationSeconds, dimensions });
};
element.onerror = () => {
cleanup();
reject(new Error('读取媒体元数据失败'));
};
element.src = objectUrl;
});
}