perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
|
||||
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, RefreshCw, Video } from "lucide-react";
|
||||
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
|
||||
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
@@ -14,7 +14,8 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
@@ -56,6 +57,7 @@ export type CanvasNodeContentProps = {
|
||||
onToggleBatch?: () => void;
|
||||
reduceMediaEffects?: boolean;
|
||||
mediaActive?: boolean;
|
||||
onMediaPlayRequest?: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodeContent(props: CanvasNodeContentProps) {
|
||||
@@ -419,11 +421,10 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
return content;
|
||||
}
|
||||
|
||||
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false }: CanvasNodeContentProps) {
|
||||
function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) {
|
||||
const playerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { updateMediaNode } = useCanvasNodeActions();
|
||||
const hasPassivePreview = Boolean(canvasNodeVideoPreviewUrl(node));
|
||||
const { url, loading } = useNodeResourceUrl(node, mediaActive || !hasPassivePreview);
|
||||
const { url, loading } = useVideoPlaybackUrl(node, mediaActive);
|
||||
const subtitleEntries = node.metadata?.subtitleEntries || [];
|
||||
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
|
||||
const [currentTimeMs, setCurrentTimeMs] = useState(0);
|
||||
@@ -452,7 +453,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
|
||||
|
||||
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
|
||||
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} sourceUrl={url} sourceLoading={loading} />;
|
||||
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} onPlay={() => onMediaPlayRequest?.(node.id)} />;
|
||||
if (!url) return <MediaLoadingState icon={<LoaderCircle className="size-5 animate-spin" />} label={loading ? "正在加载视频" : "视频资源不可用"} />;
|
||||
|
||||
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
|
||||
@@ -464,7 +465,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
return (
|
||||
<div ref={playerBoxRef} className="relative flex h-full w-full items-center justify-center overflow-hidden rounded-[var(--node-radius)] bg-black">
|
||||
<div className="relative" style={{ width: fitWidth, height: Math.round(fitHeight) }}>
|
||||
<VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} hasAudio={inferVideoHasAudio(node.metadata)} autoPlay={mediaActive} preload={reduceMediaEffects ? "none" : "metadata"} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />
|
||||
<VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} hasAudio={inferVideoHasAudio(node.metadata)} autoPlay preload="metadata" brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls onPlay={() => scheduleResourceBlobCache(node.metadata?.storageKey || "")} />
|
||||
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,18 +488,20 @@ function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
|
||||
return <CanvasAudioPlayer node={node} theme={theme} />;
|
||||
}
|
||||
|
||||
function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick<CanvasNodeContentProps, "node" | "theme"> & { sourceUrl: string; sourceLoading: boolean }) {
|
||||
function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentProps, "node" | "theme"> & { onPlay: () => void }) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(previewRef);
|
||||
const previewUrl = canvasNodeVideoPreviewUrl(node);
|
||||
const { updateMetadata } = useCanvasNodeActions();
|
||||
const updateMetadataRef = useRef(updateMetadata);
|
||||
const [hydrating, setHydrating] = useState(() => !previewUrl && Boolean(node.metadata?.content));
|
||||
const [hydrating, setHydrating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateMetadataRef.current = updateMetadata;
|
||||
}, [updateMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewUrl || !node.metadata?.content || !updateMetadataRef.current) {
|
||||
if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current) {
|
||||
setHydrating(false);
|
||||
return;
|
||||
}
|
||||
@@ -513,32 +516,47 @@ function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick<Ca
|
||||
if (!controller.signal.aborted) setHydrating(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
|
||||
}, [nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
|
||||
|
||||
if (previewUrl) {
|
||||
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black"><img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" /></div>;
|
||||
}
|
||||
if (sourceUrl) {
|
||||
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
|
||||
<video
|
||||
src={sourceUrl}
|
||||
aria-label={`${node.title || "视频"} 首帧预览`}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
draggable={false}
|
||||
className="pointer-events-none size-full select-none object-contain"
|
||||
onLoadedMetadata={(event) => primePassiveVideoFrame(event.currentTarget)}
|
||||
onLoadedData={(event) => primePassiveVideoFrame(event.currentTarget)}
|
||||
/>
|
||||
{hydrating ? <span className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-1 text-[var(--fs-tiny)] text-white/70 backdrop-blur">正在保存首帧</span> : null}
|
||||
return <div ref={previewRef} className="group/video-preview relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
|
||||
<img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" />
|
||||
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
|
||||
</div>;
|
||||
}
|
||||
return <InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={sourceLoading || hydrating ? "正在加载首帧" : "视频资源暂不可用"} theme={theme} />;
|
||||
return <div ref={previewRef} className="group/video-preview relative size-full">
|
||||
<InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : nearViewport ? "点击播放视频" : "进入视口后加载首帧"} theme={theme} />
|
||||
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function primePassiveVideoFrame(video: HTMLVideoElement) {
|
||||
if (video.currentTime === 0 && Number.isFinite(video.duration) && video.duration > 0) video.currentTime = Math.min(0.001, video.duration);
|
||||
function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPlay: () => void }) {
|
||||
return <button type="button" className="absolute left-1/2 top-1/2 z-[var(--node-z-overlay)] grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/62 text-white shadow-lg backdrop-blur transition-[transform,background-color,opacity] hover:scale-105 hover:bg-black/78 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label={`播放 ${title}`} title="播放视频" onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onPlay(); }}><Play className="ml-0.5 size-5 fill-current" /></button>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!active) {
|
||||
setUrl("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
void resolveMediaUrl(storageKey, fallback)
|
||||
.then((resolved) => { if (!cancelled) setUrl(resolved); })
|
||||
.catch(() => { if (!cancelled) setUrl(fallback); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [active, fallback, storageKey]);
|
||||
|
||||
return { url, loading };
|
||||
}
|
||||
|
||||
function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {
|
||||
|
||||
Reference in New Issue
Block a user