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 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) { 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((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((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((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; }); }