fix(canvas): 远程媒体 - 统一资源地址并校验视频缓存
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveImageUrl } from "@/services/image-storage";
|
||||
|
||||
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
@@ -13,11 +12,13 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
|
||||
* 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。
|
||||
* 远程资源图片统一使用稳定的资源文件地址。
|
||||
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
|
||||
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
|
||||
*/
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
const remoteResource = Boolean(resourceId);
|
||||
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
|
||||
const targetRef = useRef<HTMLSpanElement>(null);
|
||||
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
|
||||
@@ -51,29 +52,14 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
|
||||
let cancelled = false;
|
||||
setCacheFailed(false);
|
||||
|
||||
if (remoteResource && storageKey) {
|
||||
if (remoteResource && resourceId) {
|
||||
if (!nearViewport) {
|
||||
setCachedSrc("");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setCachedSrc("");
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve
|
||||
.then((url) => {
|
||||
if (!cancelled) {
|
||||
setCachedSrc(url || src);
|
||||
setCacheFailed(!url && !src);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 缓存读取失败时仍允许原始地址加载,真正的解码失败再显示占位。
|
||||
setCacheFailed(!src);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
setCachedSrc(resourceFileUrl(resourceId));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -96,7 +82,7 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localImageResource, nearViewport, remoteResource, src, storageKey]);
|
||||
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
|
||||
|
||||
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
|
||||
|
||||
@@ -9,8 +9,6 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
|
||||
import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment";
|
||||
import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights";
|
||||
@@ -62,8 +60,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
// 打开弹窗时解析视频地址,用于字幕叠加预览。
|
||||
// 远端资源优先走节点同款缓存下载(对象 URL),失败再退回资源代理地址。
|
||||
// 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
@@ -72,25 +69,9 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
setVideoError(false);
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) {
|
||||
setVideoUrl(cached);
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Pause, Play } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import { createDefaultSubtitleStyle } from "@/types/timeline";
|
||||
import type { TimelineClip } from "@/types/timeline";
|
||||
@@ -57,28 +55,12 @@ export function CanvasTimelinePreview({ clips, nodes, playheadMs, playing, theme
|
||||
setVideoSize(null);
|
||||
if (!node && !media) return;
|
||||
let cancelled = false;
|
||||
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套缓存/回退解析策略
|
||||
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套稳定资源地址解析策略。
|
||||
const storageKey = node?.metadata?.storageKey || media?.storageKey || "";
|
||||
const fallback = node?.metadata?.content || media?.url || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) {
|
||||
setVideoUrl(cached);
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,7 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -49,22 +47,9 @@ export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: Canva
|
||||
setFrames([]);
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) setVideoUrl(cached);
|
||||
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } f
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
|
||||
import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
@@ -82,22 +80,9 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend");
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) setVideoUrl(cached);
|
||||
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user