| 1 | /** |
| 2 | * media.ts - 媒体文件工具函数 |
| 3 | */ |
| 4 | |
| 5 | import type { Options as ImageCompressionOptions } from 'browser-image-compression' |
| 6 | |
| 7 | const IMAGE_UPLOAD_COMPRESSION_THRESHOLD = 1024 * 1024 |
| 8 | const IMAGE_UPLOAD_MAX_SIZE_MB = 1 |
| 9 | const IMAGE_UPLOAD_MAX_WIDTH_OR_HEIGHT = 2560 |
| 10 | const IMAGE_UPLOAD_INITIAL_QUALITY = 0.92 |
| 11 | |
| 12 | const COMPRESSIBLE_UPLOAD_IMAGE_TYPES = new Set([ |
| 13 | 'image/jpeg', |
| 14 | 'image/jpg', |
| 15 | 'image/png', |
| 16 | 'image/webp', |
| 17 | ]) |
| 18 | |
| 19 | const FALLBACK_IMAGE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'heic', 'heif'] |
| 20 | const FALLBACK_VIDEO_FILE_EXTENSIONS = ['mp4', 'mov', 'm4v', 'webm', 'avi', 'mkv'] |
| 21 | const FALLBACK_AUDIO_FILE_EXTENSIONS = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] |
| 22 | |
| 23 | export type MediaFileType = 'image' | 'video' | 'audio' | 'document' |
| 24 | |
| 25 | export interface MediaFileTypeOptions { |
| 26 | imageFormats?: string[] |
| 27 | videoFormats?: string[] |
| 28 | audioFormats?: string[] |
| 29 | } |
| 30 | |
| 31 | export interface MediaAcceptOptions extends MediaFileTypeOptions { |
| 32 | canUploadImage: boolean |
| 33 | canUploadVideo: boolean |
| 34 | canUploadAudio: boolean |
| 35 | } |
| 36 | |
| 37 | export interface OptimizeImageForUploadOptions { |
| 38 | signal?: AbortSignal |
| 39 | } |
| 40 | |
| 41 | function normalizeMediaFormat(format: string) { |
| 42 | return format.trim().replace(/^\./, '').toLowerCase() |
| 43 | } |
| 44 | |
| 45 | function normalizeMediaFormats(formats?: string[]) { |
| 46 | return formats?.map(normalizeMediaFormat).filter(Boolean) ?? [] |
| 47 | } |
| 48 | |
| 49 | function includesMediaExtension(extension: string, formats: string[]) { |
| 50 | return extension.length > 0 && formats.includes(extension) |
| 51 | } |
| 52 | |
| 53 | function getMediaFileExtension(fileOrName: Pick<File, 'name'> | string) { |
| 54 | const name = (typeof fileOrName === 'string' ? fileOrName : fileOrName.name).toLowerCase() |
| 55 | const dotIndex = name.lastIndexOf('.') |
| 56 | return dotIndex >= 0 ? name.slice(dotIndex + 1) : '' |
| 57 | } |
| 58 | |
| 59 | export function isMediaFileFormatAllowed(file: Pick<File, 'name'>, formats?: string[]) { |
| 60 | const normalizedFormats = normalizeMediaFormats(formats) |
| 61 | if (normalizedFormats.length === 0) |
| 62 | return true |
| 63 | |
| 64 | return includesMediaExtension(getMediaFileExtension(file), normalizedFormats) |
| 65 | } |
| 66 | |
| 67 | export function getMediaTypeFromFile(file: File, options: MediaFileTypeOptions = {}): MediaFileType { |
| 68 | const contentType = file.type.toLowerCase() |
| 69 | if (contentType.startsWith('image/')) |
| 70 | return 'image' |
| 71 | if (contentType.startsWith('video/')) |
| 72 | return 'video' |
| 73 | if (contentType.startsWith('audio/')) |
| 74 | return 'audio' |
| 75 | |
| 76 | const extension = getMediaFileExtension(file) |
| 77 | if (includesMediaExtension(extension, normalizeMediaFormats(options.imageFormats ?? FALLBACK_IMAGE_FILE_EXTENSIONS))) |
| 78 | return 'image' |
| 79 | if (includesMediaExtension(extension, normalizeMediaFormats(options.videoFormats ?? FALLBACK_VIDEO_FILE_EXTENSIONS))) |
| 80 | return 'video' |
| 81 | if (includesMediaExtension(extension, normalizeMediaFormats(options.audioFormats ?? FALLBACK_AUDIO_FILE_EXTENSIONS))) |
| 82 | return 'audio' |
| 83 | |
| 84 | return 'document' |
| 85 | } |
| 86 | |
| 87 | function toAcceptExtension(format: string) { |
| 88 | const normalizedFormat = normalizeMediaFormat(format) |
| 89 | return normalizedFormat ? `.${normalizedFormat}` : '' |
| 90 | } |
| 91 | |
| 92 | function appendAcceptTypes(target: string[], formats: string[] | undefined, fallback: string) { |
| 93 | const extensions = normalizeMediaFormats(formats).map(toAcceptExtension).filter(Boolean) |
| 94 | target.push(...(extensions.length > 0 ? extensions : [fallback])) |
| 95 | } |
| 96 | |
| 97 | export function buildMediaAcceptTypes(options: MediaAcceptOptions) { |
| 98 | const acceptTypes: string[] = [] |
| 99 | if (options.canUploadImage) |
| 100 | appendAcceptTypes(acceptTypes, options.imageFormats, 'image/*') |
| 101 | if (options.canUploadVideo) |
| 102 | appendAcceptTypes(acceptTypes, options.videoFormats, 'video/*') |
| 103 | if (options.canUploadAudio) |
| 104 | appendAcceptTypes(acceptTypes, options.audioFormats, 'audio/*') |
| 105 | |
| 106 | return Array.from(new Set(acceptTypes)).join(',') |
| 107 | } |
| 108 | |
| 109 | function getUploadImageFileExtension(contentType: string) { |
| 110 | if (contentType === 'image/png') |
| 111 | return '.png' |
| 112 | if (contentType === 'image/webp') |
| 113 | return '.webp' |
| 114 | if (contentType === 'image/jpeg' || contentType === 'image/jpg') |
| 115 | return '.jpg' |
| 116 | |
| 117 | return '' |
| 118 | } |
| 119 | |
| 120 | function getUploadImageFileName(file: File | Blob) { |
| 121 | if ('name' in file && typeof file.name === 'string' && file.name) |
| 122 | return file.name |
| 123 | |
| 124 | return `image_${Date.now()}${getUploadImageFileExtension(file.type)}` |
| 125 | } |
| 126 | |
| 127 | function getCompressionFileType(contentType: string) { |
| 128 | return contentType === 'image/jpg' ? 'image/jpeg' : contentType |
| 129 | } |
| 130 | |
| 131 | function isAbortError(error: unknown) { |
| 132 | return error instanceof DOMException && error.name === 'AbortError' |
| 133 | } |
| 134 | |
| 135 | function shouldOptimizeImageForUpload(file: File | Blob) { |
| 136 | return file.size > IMAGE_UPLOAD_COMPRESSION_THRESHOLD |
| 137 | && COMPRESSIBLE_UPLOAD_IMAGE_TYPES.has(file.type.toLowerCase()) |
| 138 | } |
| 139 | |
| 140 | function toImageCompressionFile(file: File | Blob) { |
| 141 | if (typeof File === 'undefined') |
| 142 | return null |
| 143 | |
| 144 | if (file instanceof File) |
| 145 | return file |
| 146 | |
| 147 | return new File([file], getUploadImageFileName(file), { |
| 148 | type: getCompressionFileType(file.type), |
| 149 | lastModified: Date.now(), |
| 150 | }) |
| 151 | } |
| 152 | |
| 153 | /** 上传前优化图片:超过 1MB 的 jpeg/png/webp 会限制最长边并尽量压缩到 1MB 内 */ |
| 154 | export async function optimizeImageForUpload(file: File | Blob, options?: OptimizeImageForUploadOptions) { |
| 155 | if (!shouldOptimizeImageForUpload(file) || typeof window === 'undefined') |
| 156 | return file |
| 157 | |
| 158 | if (options?.signal?.aborted) |
| 159 | throw new DOMException('上传已取消', 'AbortError') |
| 160 | |
| 161 | const compressionFile = toImageCompressionFile(file) |
| 162 | if (!compressionFile) |
| 163 | return file |
| 164 | |
| 165 | try { |
| 166 | const { default: imageCompression } = await import('browser-image-compression') |
| 167 | const compressionOptions: ImageCompressionOptions = { |
| 168 | maxSizeMB: IMAGE_UPLOAD_MAX_SIZE_MB, |
| 169 | maxWidthOrHeight: IMAGE_UPLOAD_MAX_WIDTH_OR_HEIGHT, |
| 170 | initialQuality: IMAGE_UPLOAD_INITIAL_QUALITY, |
| 171 | useWebWorker: false, |
| 172 | fileType: getCompressionFileType(compressionFile.type), |
| 173 | signal: options?.signal, |
| 174 | } |
| 175 | const compressedFile = await imageCompression(compressionFile, compressionOptions) |
| 176 | |
| 177 | return compressedFile.size < file.size ? compressedFile : file |
| 178 | } |
| 179 | catch (error) { |
| 180 | if (options?.signal?.aborted) |
| 181 | throw new DOMException('上传已取消', 'AbortError') |
| 182 | if (isAbortError(error)) |
| 183 | throw error |
| 184 | |
| 185 | console.warn('图片压缩失败,使用原图上传:', error) |
| 186 | return file |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /** 获取音频文件时长(秒),通过临时 audio 元素读取 metadata */ |
| 191 | export function getAudioDuration(file: File): Promise<number> { |
| 192 | return new Promise((resolve, reject) => { |
| 193 | const audio = document.createElement('audio') |
| 194 | audio.preload = 'metadata' |
| 195 | |
| 196 | const cleanup = () => { |
| 197 | URL.revokeObjectURL(audio.src) |
| 198 | audio.remove() |
| 199 | } |
| 200 | |
| 201 | audio.onloadedmetadata = () => { |
| 202 | const duration = audio.duration |
| 203 | cleanup() |
| 204 | resolve(Math.round(duration * 10) / 10) |
| 205 | } |
| 206 | |
| 207 | audio.onerror = () => { |
| 208 | cleanup() |
| 209 | reject(new Error('Failed to load audio metadata')) |
| 210 | } |
| 211 | |
| 212 | audio.src = URL.createObjectURL(file) |
| 213 | }) |
| 214 | } |
| 215 | |
| 216 | /** 获取视频元信息:时长 + 宽高 */ |
| 217 | export function getVideoMeta(file: File): Promise<{ duration: number, width: number, height: number }> { |
| 218 | return new Promise((resolve, reject) => { |
| 219 | const video = document.createElement('video') |
| 220 | video.preload = 'metadata' |
| 221 | const cleanup = () => { URL.revokeObjectURL(video.src); video.remove() } |
| 222 | video.onloadedmetadata = () => { |
| 223 | resolve({ |
| 224 | duration: Math.round(video.duration * 10) / 10, |
| 225 | width: video.videoWidth, |
| 226 | height: video.videoHeight, |
| 227 | }) |
| 228 | cleanup() |
| 229 | } |
| 230 | video.onerror = () => { cleanup(); reject(new Error('Failed to load video metadata')) } |
| 231 | video.src = URL.createObjectURL(file) |
| 232 | }) |
| 233 | } |
| 234 | |
| 235 | /** 从本地视频文件提取封面(data URL)和时长 */ |
| 236 | export function getVideoInfo(file: File): Promise<{ coverUrl: string, duration: number }> { |
| 237 | return new Promise((resolve, reject) => { |
| 238 | const video = document.createElement('video') |
| 239 | video.preload = 'auto' |
| 240 | const blobUrl = URL.createObjectURL(file) |
| 241 | video.src = blobUrl |
| 242 | |
| 243 | const cleanup = () => { |
| 244 | URL.revokeObjectURL(blobUrl) |
| 245 | video.remove() |
| 246 | } |
| 247 | |
| 248 | video.onloadedmetadata = () => { |
| 249 | video.currentTime = 0.1 |
| 250 | } |
| 251 | |
| 252 | video.onseeked = () => { |
| 253 | const canvas = document.createElement('canvas') |
| 254 | canvas.width = video.videoWidth |
| 255 | canvas.height = video.videoHeight |
| 256 | canvas.getContext('2d')!.drawImage(video, 0, 0) |
| 257 | const coverUrl = canvas.toDataURL('image/jpeg', 0.7) |
| 258 | const duration = Math.round(video.duration * 10) / 10 |
| 259 | cleanup() |
| 260 | resolve({ coverUrl, duration }) |
| 261 | } |
| 262 | |
| 263 | video.onerror = () => { |
| 264 | cleanup() |
| 265 | reject(new Error('Failed to load video')) |
| 266 | } |
| 267 | }) |
| 268 | } |
| 269 | |
| 270 | /** 格式化视频时长为 M:SS */ |
| 271 | export function formatVideoDuration(seconds: number): string { |
| 272 | const mins = Math.floor(seconds / 60) |
| 273 | const secs = Math.floor(seconds % 60) |
| 274 | return `${mins}:${secs.toString().padStart(2, '0')}` |
| 275 | } |
| 276 |