返回 AiToEarn
media.ts
根目录 / project / aitoearn-web / src / utils / media.ts
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
276 lines TYPESCRIPT