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

@@ -15,13 +15,13 @@ import { type CanvasNodeData, CanvasNodeType } from "@/types/canvas";
import { CanvasAudioPlayer } from "./canvas-audio-player";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveVideoMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { getActiveUserScope } from "@/lib/user-scope";
import { prepareCanvasImage } from "@/services/canvas-image-loader";
import { getResourceAccess, resolveResourceAccessURL } from "@/services/api/resources";
import { getResourceAccess, refreshResource, resourceIdFromStorageKey, resolveResourceAccessURL } from "@/services/api/resources";
import type { CanvasNodeContentProps } from "./canvas-node-content";
import { ErrorContent, LoadingContent } from "./canvas-node-status-content";
@@ -97,7 +97,7 @@ export function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchP
export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) {
const playerBoxRef = useRef<HTMLDivElement>(null);
const { updateMediaNode } = useCanvasNodeActions();
const { url, loading } = useVideoPlaybackUrl(node, mediaActive);
const { url, loading, onError } = useVideoPlaybackUrl(node, mediaActive);
const preview = canvasNodeVideoPreviewReference(node);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
@@ -159,6 +159,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay
{url ? (
<div className={`relative z-[1] transition-opacity duration-150 ${videoReady ? "opacity-100" : "opacity-0"}`} style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer
key={url}
src={url}
mimeType={node.metadata?.mimeType}
title={node.title || "视频"}
@@ -170,6 +171,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay
dataCanvasNoZoom
compactControls
onCanPlay={() => setVideoReady(true)}
onError={onError}
/>
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div>
@@ -212,7 +214,7 @@ export function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeCon
if (!element) return;
const content = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback));
return bindCanvasVideoHoverPreview(element, () => resolveVideoMediaUrl(node.metadata?.storageKey, fallback));
}, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
useEffect(() => {
@@ -313,22 +315,47 @@ export function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPla
);
}
const videoPlaybackRecoveryMaxAttempts = 8;
const videoPlaybackRecoveryDelayMs = 1_500;
function waitForVideoPlaybackRecovery(signal: AbortSignal) {
return new Promise<void>((resolve) => {
let timer = 0;
const finish = () => {
window.clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
timer = window.setTimeout(finish, videoPlaybackRecoveryDelayMs);
signal.addEventListener("abort", finish, { once: true });
});
}
export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) {
const rawContent = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent;
const storageKey = node.metadata?.storageKey || "";
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const recoveryRef = useRef<AbortController | null>(null);
const recoveryAttemptedRef = useRef(false);
const sourceGenerationRef = useRef(0);
useEffect(() => {
sourceGenerationRef.current += 1;
recoveryAttemptedRef.current = false;
recoveryRef.current?.abort();
recoveryRef.current = null;
let cancelled = false;
if (!active) {
setUrl("");
setLoading(false);
return;
return () => {
cancelled = true;
};
}
setLoading(true);
void resolveMediaUrl(storageKey, fallback)
void resolveVideoMediaUrl(storageKey, fallback)
.then((resolved) => {
if (!cancelled) setUrl(resolved);
})
@@ -340,10 +367,69 @@ export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) {
});
return () => {
cancelled = true;
recoveryRef.current?.abort();
};
}, [active, fallback, storageKey]);
return { url, loading };
const onError = () => {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!active || !resourceId || recoveryAttemptedRef.current) {
if (!resourceId) setUrl("");
return;
}
recoveryAttemptedRef.current = true;
const controller = new AbortController();
const generation = sourceGenerationRef.current;
recoveryRef.current?.abort();
recoveryRef.current = controller;
setUrl("");
setLoading(true);
const recover = async () => {
for (let attempt = 0; attempt < videoPlaybackRecoveryMaxAttempts; attempt += 1) {
if (controller.signal.aborted || generation !== sourceGenerationRef.current) return;
if (attempt > 0) await waitForVideoPlaybackRecovery(controller.signal);
if (controller.signal.aborted || generation !== sourceGenerationRef.current) return;
let resource: Awaited<ReturnType<typeof refreshResource>>;
try {
resource = await refreshResource(resourceId);
} catch {
break;
}
const status = String(resource.playbackStatus || "").toLowerCase();
if (resource.status !== "ready" || status === "none" || status === "failed") break;
if (status !== "ready") {
if (resource.provider !== "local") break;
continue;
}
try {
const access = await getResourceAccess(storageKey, "display", "playback", "", { forceRefresh: true });
if (access.actualVariant !== "playback") continue;
const resolved = resolveResourceAccessURL(access.url);
if (!resolved) continue;
if (!controller.signal.aborted && generation === sourceGenerationRef.current) {
setUrl(resolved);
setLoading(false);
}
return;
} catch {
// A ready status can race the access endpoint; the next bounded refresh retries it.
}
}
if (!controller.signal.aborted && generation === sourceGenerationRef.current) {
setUrl("");
setLoading(false);
}
};
void recover().finally(() => {
if (recoveryRef.current === controller) recoveryRef.current = null;
});
};
return { url, loading, onError };
}
export function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {

View File

@@ -24,6 +24,7 @@ type VideoPlayerProps = {
hasAudio?: boolean;
onCanPlay?: MediaPlayerProps["onCanPlay"];
onPlay?: MediaPlayerProps["onPlay"];
onError?: MediaPlayerProps["onError"];
};
const zhCNTranslations = {
@@ -74,7 +75,7 @@ const supportedVideoMimeTypes = new Set<VideoMimeType>(["video/mp4", "video/webm
* 统一视频播放表面,保留原生媒体 URL 契约,同时提供可访问的完整控件布局。
* 画布节点需要隔离播放器手势,避免拖动进度条时被误判为拖动画布。
*/
export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, hasAudio, onCanPlay, onPlay }: VideoPlayerProps) {
export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, hasAudio, onCanPlay, onPlay, onError }: VideoPlayerProps) {
const [detectedHasAudio, setDetectedHasAudio] = useState<boolean | undefined>(undefined);
const autoPlayAttemptedRef = useRef(false);
const audioProbeGenerationRef = useRef(0);
@@ -188,6 +189,7 @@ export function VideoPlayer({ src, mimeType, title = "视频", className, brandC
style={{ "--video-brand": brandColor }}
onCanPlay={handleCanPlay}
onPlay={onPlay}
onError={onError}
onLoadedMetadata={(event) => {
const provider = event.target.provider;
const media = isVideoProvider(provider) ? provider.media : undefined;