fix(media): 视频播放 - 资源视频改走 playback 变体,失败后刷新签名并重试

画布预览、生成回填和项目水合都解析浏览器可播地址;播放失败时绕过展示缓存,等待转码完成后强制刷新 playback 地址。
This commit is contained in:
ddcat666
2026-10-08 10:54:40 +08:00
parent 2b3c6ceeaa
commit b4e5eb22a6
8 changed files with 285 additions and 101 deletions

View File

@@ -120,6 +120,11 @@ export type ResourceAccess = {
originalHeight?: number;
};
export type ResourceAccessFetchOptions = {
/** Bypass both in-memory and persisted display-address caches. */
forceRefresh?: boolean;
};
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
const accessCache = new Map<string, CachedResourceAccess>();
@@ -373,12 +378,24 @@ function assertResourceRequestCurrent(generation: number, scope: string) {
}
}
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") {
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "", fetchOptions: ResourceAccessFetchOptions = {}) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
const scope = getActiveUserScope();
const generation = accessGeneration;
const key = `${scope}:${id}:${purpose}:${variant}:${downloadName}`;
const forceRefresh = fetchOptions.forceRefresh === true;
if (forceRefresh) {
accessCache.delete(key);
accessRequests.delete(key);
if (purpose === "display") {
try {
await localForageStorageForScope(scope).removeItem(persistedDisplayAccessKey(id, variant));
} catch {
// Persisted address caching is optional; the network request below remains authoritative.
}
}
}
const cached = accessCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.value;
const pending = accessRequests.get(key);

View File

@@ -1,5 +1,5 @@
import { captureVideoPoster } from "@/lib/video-poster";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveVideoMediaUrl } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
@@ -24,7 +24,7 @@ export function hydrateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSi
async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise<HydratedCanvasVideoPreview | null> {
await waitForBrowserIdle(signal);
throwIfAborted(signal);
const source = await resolveMediaUrl(node.metadata?.storageKey, node.metadata?.content || "");
const source = await resolveVideoMediaUrl(node.metadata?.storageKey, node.metadata?.content || "");
if (!source) return null;
const captured = await captureVideoPoster(source, { signal, maxWidth: 400 });
throwIfAborted(signal);

View File

@@ -3,7 +3,7 @@ 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 { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile, type ResourceAccessVariant } from "@/services/api/resources";
import { uploadImage, type UploadedImage } from "@/services/image-storage";
import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache";
@@ -93,7 +93,17 @@ export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?:
// 缓存只影响后续读取性能,服务端资源已经成功落盘,不得把缓存失败误报为上传失败。
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 };
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;
@@ -111,12 +121,12 @@ export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?:
}
}
export async function resolveMediaUrl(storageKey?: string, fallback = "") {
export async function resolveMediaUrl(storageKey?: string, fallback = "", variant: ResourceAccessVariant = "original") {
if (!storageKey) return fallback;
const resourceId = resourceIdFromStorageKey(storageKey);
if (resourceId) {
// 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。
return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url);
return resolveResourceAccessURL((await getResourceAccess(storageKey, "display", variant)).url);
}
const cached = objectUrls.get(storageKey);
if (cached) return cached;
@@ -127,6 +137,11 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") {
return url;
}
/** Resolve a video through the browser-compatible playback variant when one exists. */
export function resolveVideoMediaUrl(storageKey?: string, fallback = "") {
return resolveMediaUrl(storageKey, fallback, "playback");
}
export async function getMediaBlob(storageKey: string) {
if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey);
return store.getItem<Blob>(storageKey);