fix(canvas): 画布与素材同步 - 修复批量缩略图、远程资源回退与重命名同步

This commit is contained in:
ddCat
2026-09-18 23:27:34 +08:00
parent 531019b368
commit b99ad798b9
19 changed files with 635 additions and 513 deletions

View File

@@ -96,6 +96,9 @@ export class ResourceUploadError extends Error {
const resourceCache = new Map<string, RemoteResource>();
const resourceRequests = new Map<string, Promise<RemoteResource>>();
const missingResourceIds = new Set<string>();
const ossUrlCache = new Map<string, { url: string; expiresAt: number }>();
const ossUrlRequests = new Map<string, Promise<string>>();
const OSS_URL_CACHE_TTL_MS = 4 * 60 * 1000;
export function resourceStorageKey(id: string) {
return `resource:${id}`;
@@ -271,14 +274,25 @@ export function refreshResource(id: string): Promise<RemoteResource> {
export async function getResourceOSSUrl(storageKey?: string) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
try {
const data = await http.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
if (!data.url) throw new Error("后端未返回对象存储地址");
return data.url;
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
throw error;
}
const cached = ossUrlCache.get(resourceCacheKey(id));
if (cached && cached.expiresAt > Date.now()) return cached.url;
const pending = ossUrlRequests.get(resourceCacheKey(id));
if (pending) return pending;
const request = (async () => {
try {
const data = await http.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
if (!data.url) throw new Error("后端未返回对象存储地址");
ossUrlCache.set(resourceCacheKey(id), { url: data.url, expiresAt: Date.now() + OSS_URL_CACHE_TTL_MS });
return data.url;
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
throw error;
} finally {
ossUrlRequests.delete(resourceCacheKey(id));
}
})();
ossUrlRequests.set(resourceCacheKey(id), request);
return request;
}
function resourceCacheKey(id: string) {
@@ -287,7 +301,7 @@ function resourceCacheKey(id: string) {
export function resourceFileUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
return `${base}/resources/${encodeURIComponent(id)}/file`;
return `${base}/resources/${encodeURIComponent(id)}/file?direct=1`;
}
function resourceProxyFileUrl(id: string) {
@@ -306,16 +320,25 @@ export function resolveResourceUrl(storageKey?: string, fallback = "") {
// 副本就绪前由后端回退原件,调用方再按需降级。
export function playbackVariantUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback`;
return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback&direct=1`;
}
export async function getResourceBlob(storageKey: string) {
export async function getResourceBlob(storageKey: string, options?: { allowProxyFallback?: boolean }) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) return null;
const url = resourceProxyFileUrl(id);
const response = await fetch(url, { credentials: isResourceUrl(url) ? "include" : "same-origin" });
if (!response.ok) return null;
return response.blob();
// A direct OSS response needs CORS to be readable as a Blob. Native <img>/<video>
// can still display it without CORS, so background cache fills must not proxy it.
try {
const ossUrl = await getResourceOSSUrl(storageKey);
const response = await fetch(ossUrl, { credentials: "omit", mode: "cors" });
if (response.ok) return response.blob();
if (!options?.allowProxyFallback || response.status === 401 || response.status === 403 || response.status === 404) return null;
} catch (error) {
if (!options?.allowProxyFallback) throw error;
}
if (!options?.allowProxyFallback) return null;
const response = await fetch(resourceProxyFileUrl(id), { credentials: "include" });
return response.ok ? response.blob() : null;
}
function extensionFromMime(mimeType: string, kind: string) {