ae5c2d8821
限制视频模式角色外观图最多九张,并在前端追加、恢复和提交链路统一收口。 在入队归一化和 durable worker 执行前限制单图、累计字节、解码边长与总像素。 补充九/十张、三十兆字节、累计大小和压缩炸弹像素边界回归。 同步更新 Spine 技术方案与团队决策记录。
483 lines
13 KiB
TypeScript
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;
|
|
});
|
|
}
|