perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -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 }) {