import localforage from "localforage"; import { nanoid } from "nanoid"; import { getActiveUserScope } from "@/lib/user-scope"; import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster"; import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; import { uploadImage, type UploadedImage } from "@/services/image-storage"; import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; preview?: UploadedImage; /** * true 表示直传失败、文件当前只存在于本机 IndexedDB。语义与 UploadedImage 一致: * 云端数据同步会用同一幂等键重传,但在那之前 `url` 是页面级 objectURL,刷新即失效。 */ pendingRemoteUpload?: boolean; /** 直传失败原因,仅在 pendingRemoteUpload 为 true 时有值。 */ remoteUploadError?: string; }; const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" }); const objectUrls = new Map(); export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise { // 直传和失败后的本地同步必须复用同一上传身份,避免响应丢失后创建第二个对象。 const storageKey = `${prefix}:${getActiveUserScope()}:${nanoid()}`; const blob = input; const previewUrl = URL.createObjectURL(blob); let retainPreviewUrl = false; try { let captured: Awaited> | undefined; if (blob.type.startsWith("video/")) { try { captured = await captureVideoPoster(previewUrl); } catch (error) { // 封面和轨道信息属于展示增强:失败不阻断原文件上传,但必须留下可诊断信号。 console.warn("读取视频封面与媒体信息失败,继续上传原文件", { mimeType: blob.type, bytes: blob.size, error }); } } // 浏览器轨道探测对部分 MP4/MOV 会误报;只有未确认存在音轨时才做二次解析, // 避免正常上传重复读取整个文件。 let parsedHasAudio: boolean | undefined; if (blob.type.startsWith("video/") && captured?.hasAudio !== true) { try { parsedHasAudio = await detectVideoAudioTrackFromBlob(blob); } catch (error) { console.warn("解析视频音轨失败,继续上传但不写入音轨结论", { mimeType: blob.type, bytes: blob.size, error }); } } const resolvedHasAudio = parsedHasAudio ?? (captured?.hasAudio === false ? undefined : captured?.hasAudio); let meta: { width?: number; height?: number; durationMs?: number; hasAudio?: boolean }; if (captured) { meta = { width: captured.width, height: captured.height, durationMs: captured.durationMs, hasAudio: resolvedHasAudio }; } else if (blob.type.startsWith("audio/")) { try { meta = await readAudioMeta(previewUrl); } catch (error) { console.warn("读取音频时长失败,继续上传原文件", { mimeType: blob.type, bytes: blob.size, error }); meta = {}; } } else { meta = { hasAudio: resolvedHasAudio }; } let poster: UploadedImage | undefined; if (captured?.poster) { try { poster = await uploadImage(captured.poster); } catch (error) { // 预览图失败不应把已经可用的视频降级成本地文件;视频本体仍按强校验上传。 console.warn("上传视频预览图失败,继续保存视频本体", { mimeType: blob.type, bytes: blob.size, error }); } } let remoteUploadError = ""; try { const kind = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file"; const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey }, onProgress); try { await primeResourceBlobCache(resourceStorageKey(resource.id), blob); } catch (error) { // 缓存只影响后续读取性能,服务端资源已经成功落盘,不得把缓存失败误报为上传失败。 console.warn("预热媒体缓存失败,服务端资源已保存", { resourceId: resource.id, error }); } return { url: resource.publicUrl || resourceFileUrl(resource.id), storageKey: resourceStorageKey(resource.id), bytes: resource.size || blob.size, mimeType: resource.mimeType || blob.type || "application/octet-stream", width: resource.width || meta.width, height: resource.height || meta.height, durationMs: resource.durationMs || meta.durationMs, hasAudio: meta.hasAudio, preview: poster }; } catch (error) { // 与图片上传同一套判定:永久性失败必须当场暴露,不能混进“稍后自动同步”。 if (error instanceof ResourceUploadError && error.permanent) throw error; remoteUploadError = error instanceof Error ? error.message : "媒体直传失败"; } // 瞬时失败退回本机:文件仍可用,且云端数据同步会用同一幂等键重传。 await store.setItem(storageKey, blob); retainPreviewUrl = true; objectUrls.set(storageKey, previewUrl); return { url: previewUrl, storageKey, bytes: blob.size, mimeType: blob.type || "application/octet-stream", ...meta, preview: poster, pendingRemoteUpload: true, remoteUploadError }; } finally { // 只有本地降级结果需要把 objectURL 留给页面;成功上传和所有异常路径都及时释放。 if (!retainPreviewUrl) URL.revokeObjectURL(previewUrl); } } export async function resolveMediaUrl(storageKey?: string, fallback = "") { if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { // 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。 return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url); } const cached = objectUrls.get(storageKey); if (cached) return cached; const blob = await store.getItem(storageKey); if (!blob) return fallback; const url = URL.createObjectURL(blob); objectUrls.set(storageKey, url); return url; } export async function getMediaBlob(storageKey: string) { if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey); return store.getItem(storageKey); } export async function setMediaBlob(storageKey: string, blob: Blob) { if (resourceIdFromStorageKey(storageKey)) return primeResourceBlobCache(storageKey, blob); await store.setItem(storageKey, blob); const url = URL.createObjectURL(blob); objectUrls.set(storageKey, url); return url; } export async function deleteStoredMedia(keys: Iterable) { await Promise.all( Array.from(new Set(keys)).map(async (key) => { if (resourceIdFromStorageKey(key)) return; const url = objectUrls.get(key); if (url) URL.revokeObjectURL(url); objectUrls.delete(key); await store.removeItem(key); }), ); } export async function cleanupUnusedMedia(usedData: unknown, scope = getActiveUserScope()) { const usedKeys = collectMediaStorageKeys(usedData); const currentScope = scope; const unused: string[] = []; await store.iterate((_value, key) => { const parts = key.split(":"); if (parts.length >= 3 && parts[1] === currentScope && !usedKeys.has(key)) unused.push(key); }); await Promise.all(unused.map((key) => store.removeItem(key))); } export function collectMediaStorageKeys(value: unknown, keys = new Set()) { if (!value || typeof value !== "object") return keys; if ("storageKey" in value && typeof value.storageKey === "string" && (value.storageKey.includes(":") || resourceIdFromStorageKey(value.storageKey))) keys.add(value.storageKey); Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectMediaStorageKeys(child, keys)) : collectMediaStorageKeys(item, keys))); return keys; } function readAudioMeta(url: string) { return new Promise<{ durationMs?: number }>((resolve) => { const audio = document.createElement("audio"); const done = () => resolve({ durationMs: Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined }); audio.onloadedmetadata = done; audio.onerror = done; audio.src = url; }); }