// 画布节点的媒体内容:图片、视频、音频的预览、懒加载与失活占位。 // // 视频只有在进入视口且被激活时才加载真实播放地址(useNearViewport / useVideoPlaybackUrl), // 避免大画布上同时拉取大量视频。 import { Image as ImageIcon, LoaderCircle, Music2, Play, Video } from "lucide-react"; import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; import { useCanvasNodeActions } from "./canvas-node-action-context"; import { canvasNodeVideoPreviewReference } from "@/lib/canvas/canvas-media-preview"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import { CanvasVideoPreviewImage } from "./canvas-video-preview-image"; import { VideoPlayer } from "@/components/video-player"; import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; 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 { resolveVideoMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { type CanvasTheme } from "@/lib/canvas-theme"; import { fitImageMaterialNodeSize, fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { getActiveUserScope } from "@/lib/user-scope"; import { prepareCanvasImage } from "@/services/canvas-image-loader"; import { getResourceAccess, refreshResource, resourceIdFromStorageKey, resolveResourceAccessURL } from "@/services/api/resources"; import type { CanvasNodeContentProps } from "./canvas-node-content"; import { ErrorContent, LoadingContent } from "./canvas-node-status-content"; export function ImageNodeContent(props: CanvasNodeContentProps) { const hasImage = Boolean(props.node.metadata?.content || props.node.metadata?.storageKey); if (!hasImage && props.isBatchRoot) { const content = props.node.metadata?.status === "loading" ? ( ) : props.node.metadata?.status === "error" ? ( ) : ( ); return ( {content} ); } if (!hasImage) return ; return ( ); } export function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; const content = (
{isCharacterReference ? (
{node.metadata?.characterName || node.title}
多视角参考 · 待生成
) : ( 空图片节点 )}
); if (isBatchRoot) return ( {content} ); return content; } export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) { const playerBoxRef = useRef(null); const { updateMediaNode } = useCanvasNodeActions(); const { url, loading, onError } = useVideoPlaybackUrl(node, mediaActive); const preview = canvasNodeVideoPreviewReference(node); const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); const [currentTimeMs, setCurrentTimeMs] = useState(0); const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); const [videoReady, setVideoReady] = useState(false); useEffect(() => { setVideoReady(false); }, [url]); useEffect(() => { const box = playerBoxRef.current; const video = box?.querySelector("video"); if (!video) return; const handleLoadedMetadata = () => { if (video.videoWidth <= 0 || video.videoHeight <= 0) return; setVideoSize({ width: video.videoWidth, height: video.videoHeight }); if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) { updateMediaNode?.(node.id, (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: video.videoWidth, naturalHeight: video.videoHeight } })); } }; video.addEventListener("loadedmetadata", handleLoadedMetadata); handleLoadedMetadata(); if (!subtitleEntries.length) return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata); const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000)); video.addEventListener("timeupdate", handleTimeUpdate); return () => { video.removeEventListener("timeupdate", handleTimeUpdate); video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; }, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]); if (!node.metadata?.content && !node.metadata?.storageKey) return } label="空视频节点" color={theme.node.placeholder} />; if (!mediaActive) return onMediaPlayRequest?.(node.id)} />; const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height); const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio)); const fitWidth = Math.round(fitHeight * sourceRatio); const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined; return (
{preview ? ( } fallback={
); } export function inferVideoHasAudio(metadata: CanvasNodeData["metadata"]): boolean | undefined { if (typeof metadata?.hasAudio === "boolean") return metadata.hasAudio; // Generated nodes from older saves may not have `hasAudio` yet. In that // case an explicit generation setting is the only persisted signal we // have; leave all other videos in the unknown state. const value = metadata?.generateAudio?.trim().toLowerCase(); if (["false", "0", "off", "no", "disabled"].includes(value || "")) return false; if (["true", "1", "on", "yes", "enabled"].includes(value || "")) return true; return undefined; } export function AudioNodeContent({ node, theme }: CanvasNodeContentProps) { if (!node.metadata?.content && !node.metadata?.storageKey) return } label="空音频节点" color={theme.node.placeholder} />; return ; } export function InactiveVideoPreview({ node, theme, onPlay }: Pick & { onPlay: () => void }) { const previewRef = useRef(null); const nearViewport = useNearViewport(previewRef); const persistedPreview = canvasNodeVideoPreviewReference(node); const persistedPreviewSource = persistedPreview?.src || ""; const persistedPreviewStorageKey = persistedPreview?.storageKey || ""; const hasPersistedPreview = Boolean(persistedPreviewSource || persistedPreviewStorageKey); const { updateMetadata } = useCanvasNodeActions(); const updateMetadataRef = useRef(updateMetadata); const [hydrating, setHydrating] = useState(false); const [localPreviewUrl, setLocalPreviewUrl] = useState(""); const localPreviewUrlRef = useRef(""); useEffect(() => { const element = previewRef.current; if (!element) return; const content = node.metadata?.content || ""; const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content; return bindCanvasVideoHoverPreview(element, () => resolveVideoMediaUrl(node.metadata?.storageKey, fallback)); }, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]); useEffect(() => { updateMetadataRef.current = updateMetadata; }, [updateMetadata]); useEffect( () => () => { if (localPreviewUrlRef.current) URL.revokeObjectURL(localPreviewUrlRef.current); }, [], ); useEffect(() => { if (hasPersistedPreview || !nearViewport || (!node.metadata?.content && !node.metadata?.storageKey) || !updateMetadataRef.current) { if (hasPersistedPreview && localPreviewUrlRef.current) { URL.revokeObjectURL(localPreviewUrlRef.current); localPreviewUrlRef.current = ""; setLocalPreviewUrl(""); } setHydrating(false); return; } if (localPreviewUrlRef.current) { URL.revokeObjectURL(localPreviewUrlRef.current); localPreviewUrlRef.current = ""; setLocalPreviewUrl(""); } const controller = new AbortController(); setHydrating(true); void hydrateCanvasVideoPreview(node, controller.signal) .then((hydrated) => { if (!hydrated || controller.signal.aborted) { if (hydrated?.localUrl) URL.revokeObjectURL(hydrated.localUrl); return; } if (localPreviewUrlRef.current) URL.revokeObjectURL(localPreviewUrlRef.current); localPreviewUrlRef.current = hydrated.localUrl; setLocalPreviewUrl(hydrated.localUrl); void hydrated.persisted.then((videoPreview) => { if (!controller.signal.aborted && videoPreview) updateMetadataRef.current?.(node.id, { videoPreview }); }); }) .catch(() => undefined) .finally(() => { if (!controller.signal.aborted) setHydrating(false); }); return () => { controller.abort(); }; }, [hasPersistedPreview, nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, persistedPreviewSource, persistedPreviewStorageKey]); if (hasPersistedPreview || localPreviewUrl) { return (
{hasPersistedPreview ? ( } fallback={
); } return (
} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : nearViewport ? "点击播放视频" : "进入视口后加载首帧"} theme={theme} />
); } export function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPlay: () => void }) { return ( ); } const videoPlaybackRecoveryMaxAttempts = 8; const videoPlaybackRecoveryDelayMs = 1_500; function waitForVideoPlaybackRecovery(signal: AbortSignal) { return new Promise((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(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 () => { cancelled = true; }; } setLoading(true); void resolveVideoMediaUrl(storageKey, fallback) .then((resolved) => { if (!cancelled) setUrl(resolved); }) .catch(() => { if (!cancelled) setUrl(fallback); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; recoveryRef.current?.abort(); }; }, [active, fallback, storageKey]); 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>; 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 }) { return (
{icon} {title} {hint}
); } export function MediaLoadingState({ icon, label }: { icon: ReactNode; label: string }) { return (
{icon} {label}
); } export function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) { return (
{icon} {label}
); } export function ImageContent({ renderLOD = "full", node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch, }: Pick) { const imageContainerRef = useRef(null); const nearViewport = useNearViewport(imageContainerRef, renderLOD !== "shell"); const resourceVariant = renderLOD === "full" && (node.metadata?.imageLayer || node.metadata?.imageLayerGroup) ? "original" : "thumbnail"; const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, resourceVariant); const importedFromLibTV = node.metadata?.importSource?.provider === "libtv"; const { updateMediaNode } = useCanvasNodeActions(); const measuredSizeRef = useRef<{ width: number; height: number } | null>(null); /** * 让节点跟随图片真实比例。 * * 上传接口的 width/height 是可选的(services/api/resources.ts),拿不到时节点会落到 * 默认的横向比例,竖图就被放进一个宽盒子、两侧留黑。这里在图片解码后量真实尺寸并校正。 * * 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」——后者会让所有已经 * 存过 naturalWidth 的旧节点永远得不到修正(第一版就是这么写的,所以没生效)。 * 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。 */ const fitToImage = (element: HTMLImageElement, sourceSize?: { width?: number; height?: number }) => { // LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。 if (importedFromLibTV) return; const naturalWidth = sourceSize?.width || element.naturalWidth; const naturalHeight = sourceSize?.height || element.naturalHeight; if (!naturalWidth || !naturalHeight) return; if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return; measuredSizeRef.current = { width: naturalWidth, height: naturalHeight }; updateMediaNode?.(node.id, (current) => { const metadata = current.metadata; const needsMetadata = metadata?.naturalWidth !== naturalWidth || metadata?.naturalHeight !== naturalHeight; if (current.metadata?.freeResize || current.metadata?.manualSize) { return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current; } // 独立素材使用有界预览框;解码回调不能重新把细长文字放大成超宽节点。 const materialPreview = metadata?.imageLayerMaterial || (metadata?.isBatchRoot && batchPreviewNodes?.some((child) => child.metadata?.imageLayerMaterial)); const size = materialPreview ? fitImageMaterialNodeSize(naturalWidth, naturalHeight) : fitNodeSize(naturalWidth, naturalHeight); const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1; if (!needsMetadata && !needsResize) return current; return { ...current, ...(needsResize ? size : {}), metadata: needsMetadata ? { ...metadata, naturalWidth, naturalHeight } : metadata, }; }); }; return (
{node.metadata?.imageLayerGroup?.incomplete ? (
部分合成:{node.metadata.imageLayerGroup.incomplete.completed}/{node.metadata.imageLayerGroup.incomplete.total} 层已通过{node.metadata.imageLayerGroup.incomplete.missingBackground ? ",缺少完整背景" : ""},展开可查看并重试失败层
) : null} {node.metadata?.imageLayerGroup?.compositeStatus === "updating" || node.metadata?.imageLayerGroup?.compositeStatus === "error" ? (
{node.metadata.imageLayerGroup.compositeStatus === "updating" ? "正在更新合成图…" : node.metadata.imageLayerGroup.compositeError || "合成图更新失败,请在图层管理中重试"}
) : null}
); } /** A stable visible img is updated only after a queued candidate has loaded and decoded. */ export function RetainedCanvasImage({ identity, src, storageKey, fallbackSrc, originalSize, alt, fitToImage, className, loading, theme, }: { identity: string; src: string; storageKey?: string; fallbackSrc: string; originalSize: { width?: number; height?: number }; alt?: string; fitToImage: (image: HTMLImageElement, size?: { width?: number; height?: number }) => void; className: string; loading: boolean; theme: CanvasTheme; }) { const [displayed, setDisplayed] = useState<{ identity: string; src: string } | null>(null); const [preparing, setPreparing] = useState(false); const displayedRef = useRef(displayed); displayedRef.current = displayed; const originalWidth = originalSize.width; const originalHeight = originalSize.height; const currentRef = useRef({ identity, src, storageKey, fallbackSrc, originalSize, fitToImage }); currentRef.current = { identity, src, storageKey, fallbackSrc, originalSize, fitToImage }; const currentDisplayedSrc = displayed?.identity === identity ? displayed.src : ""; useEffect(() => { const controller = new AbortController(); const target = currentRef.current; if (!target.src || (displayedRef.current?.identity === identity && displayedRef.current.src === target.src)) { setPreparing(false); return () => controller.abort(); } setPreparing(true); const isCurrent = () => !controller.signal.aborted && currentRef.current.identity === identity && currentRef.current.src === target.src; const prepare = async () => { try { const candidate = await prepareCanvasImage(target.src, controller.signal); if (!isCurrent()) return; currentRef.current.fitToImage(candidate, target.originalSize); setDisplayed({ identity, src: target.src }); } catch (error) { if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; const resourceId = target.storageKey?.startsWith("resource:") ? target.storageKey.slice("resource:".length) : ""; if (!resourceId) return; try { const access = await getResourceAccess(target.storageKey, "display", "original"); const originalURL = resolveResourceAccessURL(access.url); if (!isCurrent() || !originalURL || originalURL === target.src) return; const original = await prepareCanvasImage(originalURL, controller.signal); if (!isCurrent()) return; currentRef.current.fitToImage(original, { width: access.originalWidth, height: access.originalHeight }); setDisplayed({ identity, src: originalURL }); } catch (fallbackError) { if (!controller.signal.aborted && !(fallbackError instanceof DOMException && fallbackError.name === "AbortError")) { // Keep the already visible same-resource image; a first-load failure remains an explicit placeholder. } } } finally { if (isCurrent()) setPreparing(false); } }; void prepare(); return () => controller.abort(); }, [identity, originalHeight, originalWidth, src, storageKey]); return ( <> {alt event.preventDefault()} className={`absolute inset-0 ${className}`} style={{ visibility: currentDisplayedSrc ? "visible" : "hidden" }} /> {!currentDisplayedSrc ? (
{loading || preparing ? : }
) : null} ); } export function useNodeResourceUrl(node: CanvasNodeData, eager: boolean, variant: "original" | "thumbnail" = "original") { const storageKey = node.metadata?.storageKey || ""; const rawContent = node.metadata?.content || ""; const content = node.type === CanvasNodeType.Video && node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent; // `previewContent` is intentionally passive-only. When a media node is // activated, VideoPlayer/Audio must receive the playable asset, never the // LibTV OSS snapshot URL stored for the thumbnail. const fallback = node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio ? content : node.metadata?.previewContent || (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content); const isRemoteResource = storageKey.startsWith("resource:"); // Inline data URLs are already local, but decoding thousands of them is // still expensive. Images must wait for the same viewport gate as remote // resources; otherwise DOM virtualization does not reduce image work. const isLazyVisual = node.type === CanvasNodeType.Image; const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:")); const initialUrl = eager && !isRemoteResource && isLazyVisual && isHttpUrl ? fallback : isRemoteResource || isLazyVisual ? "" : fallback; const scope = getActiveUserScope(); const identity = `${scope}:${node.id}:${storageKey || fallback}`; const [resolved, setResolved] = useState<{ identity: string; url: string; loading: boolean; originalWidth?: number; originalHeight?: number }>(() => ({ identity, url: initialUrl, loading: !initialUrl && isRemoteResource && eager })); const current = resolved.identity === identity ? resolved : { identity, url: initialUrl, loading: isRemoteResource && eager }; useEffect(() => { if (!isRemoteResource) { setResolved({ identity, url: isLazyVisual && !eager ? "" : fallback, loading: false }); return; } if (!eager) { setResolved({ identity, url: "", loading: false }); return; } let cancelled = false; setResolved({ identity, url: "", loading: true }); void getResourceAccess(storageKey, "display", variant) .then((access) => { if (!cancelled) setResolved({ identity, url: resolveResourceAccessURL(access.url), loading: false, originalWidth: access.originalWidth, originalHeight: access.originalHeight }); }) .catch(() => { if (!cancelled) setResolved({ identity, url: fallback, loading: false }); }); return () => { cancelled = true; }; }, [eager, fallback, identity, isLazyVisual, isRemoteResource, storageKey, variant]); const isThumbnail = variant === "thumbnail"; return { url: current.url, loading: current.loading, originalWidth: isThumbnail ? current.originalWidth : undefined, originalHeight: isThumbnail ? current.originalHeight : undefined }; } export function useNearViewport(ref: RefObject, enabled = true) { const [nearViewport, setNearViewport] = useState(false); useEffect(() => { if (!enabled || nearViewport) return; const element = ref.current; if (!element || typeof IntersectionObserver === "undefined") { setNearViewport(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNearViewport(true); observer.disconnect(); } }, { rootMargin: "600px" }, ); observer.observe(element); return () => observer.disconnect(); }, [enabled, nearViewport, ref]); return nearViewport; } export function BatchPreviewImage({ node }: { node: CanvasNodeData }) { const ref = useRef(null); const nearViewport = useNearViewport(ref); const { url } = useNodeResourceUrl(node, nearViewport); return (
{url ? {`子图预览:${node.title}`} : null}
); } export function BatchFrame({ batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children, }: { batchCount: number; batchPreviewNodes?: CanvasNodeData[]; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode; }) { const isBatchRoot = batchCount > 1; return (
{ event.stopPropagation(); onToggleBatch?.(); } : undefined } > {isBatchRoot ? (
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
{batchPreviewNodes?.[index] ? : null}
))}
) : null} {children}
); }