perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)

Merge origin/main at fb089b2 and retain canvas media performance, player layout, and interaction updates.

Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
wurendao007
2026-08-31 09:00:37 +08:00
committed by GitHub
parent 21facff923
commit 8ebceecbb6
58 changed files with 2662 additions and 207 deletions

View File

@@ -10,7 +10,7 @@ type InsertableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audi
export type InsertAssetPayload =
| { kind: "text"; content: string; title: string; assetId?: string }
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
@@ -75,7 +75,7 @@ export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibr
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, hasAudio: asset.data.hasAudio, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
}

View File

@@ -0,0 +1,133 @@
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { AlertCircle, LoaderCircle, Pause, Play } from "lucide-react";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { getCanvasAudioPlaybackSnapshot, seekCanvasAudio, stopCanvasAudio, subscribeCanvasAudioNode, toggleCanvasAudio } from "@/services/canvas-audio-playback";
import type { CanvasNodeData } from "@/types/canvas";
type CanvasAudioPlayerProps = {
node: CanvasNodeData;
theme: CanvasTheme;
};
export function CanvasAudioPlayer({ node, theme }: CanvasAudioPlayerProps) {
const { updateMetadata } = useCanvasNodeActions();
const subscribe = useCallback((listener: () => void) => subscribeCanvasAudioNode(node.id, listener), [node.id]);
const getSnapshot = useCallback(() => getCanvasAudioPlaybackSnapshot(node.id), [node.id]);
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const source = useMemo(
() => ({
nodeId: node.id,
content: node.metadata?.content || "",
storageKey: node.metadata?.storageKey,
mimeType: node.metadata?.mimeType,
durationMs: node.metadata?.durationMs,
}),
[node.id, node.metadata?.content, node.metadata?.durationMs, node.metadata?.mimeType, node.metadata?.storageKey],
);
const waveform = useMemo(() => createStableWaveform(node.id), [node.id]);
const syncedDurationRef = useRef(0);
const durationMs = snapshot.durationMs || node.metadata?.durationMs || 0;
const progress = durationMs > 0 ? Math.min(1, Math.max(0, snapshot.currentTimeMs / durationMs)) : 0;
useEffect(() => {
if (!snapshot.durationMs || snapshot.durationMs === node.metadata?.durationMs || syncedDurationRef.current === snapshot.durationMs) return;
syncedDurationRef.current = snapshot.durationMs;
updateMetadata?.(node.id, { durationMs: snapshot.durationMs });
}, [node.id, node.metadata?.durationMs, snapshot.durationMs, updateMetadata]);
useEffect(() => () => stopCanvasAudio(node.id), [node.id]);
const stopCanvasEvent = (event: React.SyntheticEvent) => event.stopPropagation();
const togglePlayback = () => {
void toggleCanvasAudio(source);
};
const seek = (event: React.ChangeEvent<HTMLInputElement>) => {
seekCanvasAudio(node.id, Number(event.target.value), source);
};
const label = node.title || "音频";
const isPlaying = snapshot.phase === "playing";
const isLoading = snapshot.phase === "loading";
const isError = snapshot.phase === "error";
return (
<div role="group" aria-label={`${label}播放控件`} className="flex h-full w-full min-w-0 flex-col justify-center gap-2.5 px-4" style={{ color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
aria-label={isPlaying ? `暂停${label}` : `播放${label}`}
className="grid size-10 shrink-0 place-items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-45"
style={{ borderColor: `${theme.node.activeStroke}80`, background: `${theme.node.activeStroke}18`, color: theme.node.activeStroke }}
disabled={isLoading || (!source.content && !source.storageKey)}
onPointerDown={stopCanvasEvent}
onMouseDown={stopCanvasEvent}
onClick={(event) => {
event.stopPropagation();
togglePlayback();
}}
>
{isLoading ? <LoaderCircle className="size-4 animate-spin" aria-hidden="true" /> : isPlaying ? <Pause className="size-4" aria-hidden="true" /> : <Play className="ml-0.5 size-4" aria-hidden="true" />}
</button>
<div className="relative min-w-0 flex-1">
<div className="relative mb-1 flex items-center gap-2 text-[var(--fs-label)] tabular-nums" style={{ color: theme.node.muted }}>
<span>{formatAudioTime(snapshot.currentTimeMs)}/{durationMs ? formatAudioTime(durationMs) : "--:--"}</span>
</div>
<input
type="range"
min={0}
max={Math.max(1, durationMs)}
step={1}
value={Math.min(Math.max(0, snapshot.currentTimeMs), Math.max(1, durationMs))}
aria-label={`${label}播放进度`}
className="h-3 w-full cursor-pointer accent-[var(--canvas-accent)]"
style={{ accentColor: theme.node.activeStroke }}
disabled={!durationMs || isError}
onPointerDown={stopCanvasEvent}
onMouseDown={stopCanvasEvent}
onClick={stopCanvasEvent}
onWheel={stopCanvasEvent}
onChange={seek}
/>
</div>
</div>
<div className="flex h-8 min-w-0 items-center gap-1 overflow-hidden" aria-hidden="true">
{waveform.map((height, index) => (
<span
key={`${node.id}-${index}`}
className="min-w-0 flex-1 rounded-full transition-opacity duration-150"
style={{ height: `${Math.max(18, Math.round(height * 100))}%`, background: theme.node.activeStroke, opacity: index / waveform.length <= progress ? 0.9 : 0.22 }}
/>
))}
</div>
{isError ? (
<div role="status" className="flex min-w-0 items-center gap-1.5 truncate text-[var(--fs-tiny)]" style={{ color: theme.accent.danger || theme.node.muted }}>
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{snapshot.error || "音频播放失败"}</span>
</div>
) : null}
</div>
);
}
function createStableWaveform(nodeId: string) {
let seed = 2166136261;
for (let index = 0; index < nodeId.length; index += 1) {
seed ^= nodeId.charCodeAt(index);
seed = Math.imul(seed, 16777619);
}
return Array.from({ length: 36 }, (_, index) => {
seed = Math.imul(seed ^ index, 16777619);
const normalized = ((seed >>> 0) % 100) / 100;
return 0.28 + normalized * 0.62;
});
}
function formatAudioTime(timeMs: number) {
const totalSeconds = Math.max(0, Math.floor(timeMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

View File

@@ -331,7 +331,7 @@ function ResourcePreview({ candidate }: { candidate: ComposerCandidate }) {
);
}
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
if (input.type === "video" && input.previewUrl) return <img src={input.previewUrl} alt="" className="size-9 rounded-md bg-black object-cover" loading="lazy" decoding="async" />;
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
@@ -352,9 +352,10 @@ function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationI
wrapper.dataset.referenceToken = `@${generationInputMentionLabel(input, inputs)}`;
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, chipStyle(theme));
if (input.type === "image" && input.image && input.sourceKind !== "drawing") {
const previewUrl = input.type === "image" && input.image && input.sourceKind !== "drawing" ? input.image.dataUrl : input.type === "video" ? input.previewUrl : "";
if (previewUrl) {
const image = document.createElement("img");
image.src = input.image.dataUrl;
image.src = previewUrl;
image.alt = input.title;
image.className = "size-6 rounded object-cover";
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
@@ -362,7 +363,7 @@ function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationI
wrapper.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onImagePreview(input.image?.dataUrl || "");
onImagePreview(previewUrl);
});
} else {
wrapper.title = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.text || input.title;

View File

@@ -167,8 +167,8 @@ export function activeConnectionPath(node: CanvasNodeData | undefined, handle: C
*
* 单端口的一侧(左右各一个出入口,除分镜脚本外都是)**永远取边的正中**:
* 之前按鼠标落点比例取 Y,多条线接同一个端口就会沿着边散开,视觉上像节点有很多端口。
* 真正的多端口只存在于分镜脚本的 `row:` 句柄,走下面的分支——所以这里不再接受
* anchorRatio 参数,避免「传了却被忽略」。
* 真正的多端口只存在于分镜脚本的 `row:` 句柄;普通节点始终连接到边缘
* 垂直中心,避免同一个节点因鼠标落点产生漂移的“伪端口”。
*/
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2;

View File

@@ -3,6 +3,7 @@ import { Dropdown } from "antd";
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType } from "@/types/canvas";
@@ -189,8 +190,9 @@ function FolderNodeMedia({ node }: { node?: CanvasNodeData }) {
if (node?.type === CanvasNodeType.Image && node.metadata?.content) {
return <img src={node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video && node.metadata?.content) {
return <video src={node.metadata.content} muted playsInline preload="metadata" />;
const videoPreview = node ? canvasNodeVideoPreviewUrl(node) : "";
if (videoPreview) {
return <img src={videoPreview} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Drawing && (node.metadata?.drawingPreviewUrl || node.metadata?.content)) {
return <img src={node.metadata.drawingPreviewUrl || node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;

View File

@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Video } from "lucide-react";
import { CometCard } from "@/components/ui/aceternity/comet-card";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas";
@@ -293,15 +294,16 @@ function FramePreview({ nodes, frame, theme }: { nodes: CanvasNodeData[]; frame:
return (
<div className="pointer-events-none absolute inset-x-2 bottom-2 overflow-hidden rounded-md" style={{ top: FRAME_HEADER_HEIGHT, background: theme.frame.preview }}>
{layout.length ? (
layout.map(({ node, ...style }) => (
<div key={node.id} className="absolute overflow-hidden rounded-[var(--r-xs)] border" style={{ ...style, background: theme.node.fill, borderColor: theme.node.stroke }}>
layout.map(({ node, ...style }) => {
const videoPreview = canvasNodeVideoPreviewUrl(node);
return <div key={node.id} className="absolute overflow-hidden rounded-[var(--r-xs)] border" style={{ ...style, background: theme.node.fill, borderColor: theme.node.stroke }}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} /> : null}
{node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="h-full w-full object-cover" muted playsInline preload="metadata" /> : null}
{node.type === CanvasNodeType.Video && !node.metadata?.content ? <Video className="m-auto size-4 h-full opacity-40" /> : null}
{videoPreview ? <img src={videoPreview} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} /> : null}
{node.type === CanvasNodeType.Video && !videoPreview ? <Video className="m-auto size-4 h-full opacity-40" /> : null}
{node.type === CanvasNodeType.Text ? <div className="line-clamp-3 p-1 text-[var(--fs-nano)] leading-[9px]" style={{ color: theme.node.text }}>{node.metadata?.content || node.title}</div> : null}
{node.type === CanvasNodeType.Script ? <div className="p-1 text-[var(--fs-nano)] leading-[9px]" style={{ color: theme.node.text }}>分镜脚本 · {node.metadata?.storyboard?.rows.length || 0} 镜</div> : null}
</div>
))
</div>;
})
) : (
<div className="grid h-full place-items-center text-[var(--fs-label)]" style={{ color: theme.node.faint }}>空背板</div>
)}

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, Play, RefreshCw, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, RefreshCw, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -7,18 +7,21 @@ import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskSt
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
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 { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasAudioPlayer } from "./canvas-audio-player";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
import { MarkdownNodeContent } from "./nodes/markdown-node";
@@ -50,6 +53,8 @@ export type CanvasNodeContentProps = {
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
mediaActive?: boolean;
hydrateMediaPreview?: boolean;
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
@@ -412,11 +417,10 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
return content;
}
function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContentProps) {
const playWhenReadyRef = useRef(false);
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) {
const playerBoxRef = useRef<HTMLDivElement>(null);
const { updateMetadata } = useCanvasNodeActions();
const { url, loading, load } = useNodeResourceUrl(node, false);
const { url, loading } = useNodeResourceUrl(node, mediaActive);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
@@ -445,7 +449,8 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContent
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMetadata, url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!url) return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} hydrateMediaPreview={hydrateMediaPreview} />;
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);
const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio));
@@ -456,30 +461,69 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContent
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 || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} 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={mediaActive} preload={reduceMediaEffects ? "none" : "metadata"} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div>
</div>
);
}
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;
}
function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const playWhenReadyRef = useRef(false);
const { url, loading, load } = useNodeResourceUrl(node, false);
if (!node.metadata?.content && !node.metadata?.storageKey) return <EmptyMediaContent icon={<Music2 className="size-7 opacity-35" />} label="空音频节点" color={theme.node.placeholder} />;
return <CanvasAudioPlayer node={node} theme={theme} />;
}
function InactiveVideoPreview({ node, theme, hydrateMediaPreview = false }: Pick<CanvasNodeContentProps, "node" | "theme" | "hydrateMediaPreview">) {
const previewUrl = canvasNodeVideoPreviewUrl(node);
const { updateMetadata } = useCanvasNodeActions();
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(false);
useEffect(() => {
if (!url || !playWhenReadyRef.current) return;
playWhenReadyRef.current = false;
void audioRef.current?.play().catch(() => undefined);
}, [url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Music2 className="size-7 opacity-35" />} label="空音频节点" color={theme.node.placeholder} />;
if (!url) return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
return (
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70"><Music2 className="size-4 shrink-0" /><span className="min-w-0 truncate" title={node.title || "音频"}>{node.title || "音频"}</span></div>
<audio ref={audioRef} src={url} controls preload="metadata" className="w-full" data-canvas-no-zoom />
</div>
);
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
useEffect(() => {
if (previewUrl || !hydrateMediaPreview || !node.metadata?.content || !updateMetadataRef.current) {
setHydrating(false);
return;
}
const controller = new AbortController();
setHydrating(true);
void hydrateCanvasVideoPreview(node, controller.signal)
.then((videoPreview) => {
if (!controller.signal.aborted && videoPreview) updateMetadataRef.current?.(node.id, { videoPreview });
})
.catch(() => undefined)
.finally(() => {
if (!controller.signal.aborted) setHydrating(false);
});
return () => controller.abort();
}, [hydrateMediaPreview, 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>;
}
return <InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : "选择后加载视频"} theme={theme} />;
}
function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {
return <div className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--node-radius)] px-4 text-center" style={{ background: theme.node.fill, color: theme.node.muted }}><span className="opacity-40">{icon}</span><span className="max-w-full truncate text-xs font-medium" title={title}>{title}</span><span className="text-[var(--fs-tiny)] opacity-50">{hint}</span></div>;
}
function MediaLoadingState({ icon, label }: { icon: ReactNode; label: string }) {
return <div role="status" className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--node-radius)] bg-black text-white/75"><span className="grid size-10 place-items-center rounded-full bg-white/10">{icon}</span><span className="text-xs font-medium">{label}</span></div>;
}
function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) {
@@ -526,15 +570,19 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
);
}
function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) {
return <button type="button" data-canvas-no-zoom className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--node-radius)] bg-black text-white/75 transition-colors hover:text-white disabled:cursor-wait" disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}><span className="grid size-10 place-items-center rounded-full bg-white/10">{icon}</span><span className="text-xs font-medium">{label}</span></button>;
}
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
const storageKey = node.metadata?.storageKey || "";
const content = node.metadata?.content || "";
const fallback = node.metadata?.previewContent
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
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 = Boolean(resourceIdFromStorageKey(storageKey));
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
const [loading, setLoading] = useState(isRemoteResource && eager);
@@ -559,22 +607,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
return () => { cancelled = true; };
}, [eager, fallback, isRemoteResource, storageKey]);
const load = useCallback(async () => {
if (url) return url;
if (!isRemoteResource) return fallback;
setLoading(true);
try {
const next = (await cacheResourceObjectUrl(storageKey)) || fallback;
setUrl(next);
return next;
} catch {
setUrl(fallback);
return fallback;
} finally {
setLoading(false);
}
}, [fallback, isRemoteResource, storageKey, url]);
return { url, loading, load };
return { url, loading };
}
function useNearViewport(ref: RefObject<Element | null>) {

View File

@@ -5,6 +5,7 @@ import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import { getGenerationResourceNodes, getContextResourceNodes, getMentionResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
@@ -53,6 +54,7 @@ export type NodeGenerationInput = {
type: "text" | "image" | "video" | "audio" | "character";
sourceKind?: "drawing";
title: string;
previewUrl?: string;
alwaysIncludeText?: boolean;
text?: string;
image?: ReferenceImage;
@@ -339,7 +341,7 @@ function buildGenerationInputs(resourceNodes: CanvasNodeData[], nodes: CanvasNod
// 调色节点在下游就是一张普通参考图,按 image 标签即正确。
if (image) return [{ nodeId: node.id, type: "image" as const, sourceKind: image.source?.kind === "drawing" ? "drawing" : undefined, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, previewUrl: canvasNodeVideoPreviewUrl(node), video }];
const audio = readReferenceAudio(node);
if (audio) return [{ nodeId: node.id, type: "audio" as const, title: node.title, audio }];
const text = readNodeTextInput(node);
@@ -364,7 +366,7 @@ function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
const nodeId = `asset:${asset.id}`;
if (asset.kind === "text") return [{ nodeId, type: "text", title: asset.title, text: asset.data.content }];
if (asset.kind === "image") return [{ nodeId, type: "image", title: asset.title, image: { id: asset.id, name: asset.title, type: asset.data.mimeType, dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height } }];
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, video: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs } }];
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, previewUrl: canvasVideoAssetPreviewUrl(asset.data.url, asset.coverUrl), video: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs } }];
if (asset.kind === "audio") return [{ nodeId, type: "audio", title: asset.title, audio: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, durationMs: asset.data.durationMs } }];
if (asset.kind === "entity" && asset.category === "character") return [{ nodeId, type: "character", title: asset.title, character: { nodeId, assetId: asset.id, requestedVersionId: asset.primaryVersionId } }];
return [];

View File

@@ -598,7 +598,7 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re
function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-full object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-full bg-black object-cover" muted preload="metadata" />;
if (reference.kind === "video" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-full bg-black object-cover" loading="lazy" decoding="async" />;
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-full bg-black/5 object-contain" />;
const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;

View File

@@ -4,6 +4,7 @@ import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Vide
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -124,16 +125,13 @@ const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, acti
function CanvasNodeSearchThumbnail({ node }: { node: CanvasNodeData }) {
const [failed, setFailed] = useState(false);
const mediaSource = node.metadata?.drawingPreviewUrl
const mediaSource = node.type === CanvasNodeType.Video ? canvasNodeVideoPreviewUrl(node) : node.metadata?.drawingPreviewUrl
|| node.metadata?.characterCoverUrl
|| node.metadata?.folder?.themeCover
|| ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined);
|| ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined);
const commonClass = "h-11 w-16 rounded-[var(--r-sm)] border object-cover";
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
if (mediaSource && !failed && node.type === CanvasNodeType.Video) {
return <video src={mediaSource} muted playsInline preload="metadata" aria-label={`${node.title} 视频缩略图`} className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}
if (mediaSource && !failed) {
return <img src={mediaSource} alt="" width={64} height={44} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
@@ -23,6 +23,8 @@ type CanvasNodeProps = {
dragOffset?: Position;
scale: number;
isSelected: boolean;
mediaActive?: boolean;
hydrateMediaPreview?: boolean;
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
@@ -67,6 +69,8 @@ export const CanvasNode = React.memo(function CanvasNode({
dragOffset,
scale,
isSelected,
mediaActive = false,
hydrateMediaPreview = false,
isRelated,
isFocusRelated,
isConnectionTarget,
@@ -112,7 +116,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions();
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content || data.metadata?.storageKey);
const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent);
const isComposerNode = data.type === CanvasNodeType.Config;
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
@@ -261,7 +265,7 @@ export const CanvasNode = React.memo(function CanvasNode({
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected && data.type === CanvasNodeType.Video ? "z-[var(--z-node-toolbar)]" : isSelected || isFocusRelated || isConnectionTarget ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
style={{
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
width: data.width,
@@ -282,7 +286,7 @@ export const CanvasNode = React.memo(function CanvasNode({
node={data}
scale={scale}
dimensionLabel={mediaDimensionLabel}
active={hovered || isSelected || isFocusRelated}
active={isSelected || isFocusRelated}
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
editing={isEditingTitle}
draft={titleDraft}
@@ -300,7 +304,7 @@ export const CanvasNode = React.memo(function CanvasNode({
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
border: isComposerNode ? "0" : "1px solid transparent",
boxShadow: isComposerNode ? "none" : isSelected || hovered ? theme.node.hoverShadow : theme.node.shadow,
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -338,7 +342,7 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: hasImageContent || hasVideoContent || hasAudioContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -371,6 +375,8 @@ export const CanvasNode = React.memo(function CanvasNode({
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
mediaActive={mediaActive}
hydrateMediaPreview={hydrateMediaPreview}
/>
</div>
@@ -460,8 +466,8 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
@@ -474,6 +480,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.dragOffset?.y === next.dragOffset?.y &&
previous.scale === next.scale &&
previous.isSelected === next.isSelected &&
previous.mediaActive === next.mediaActive &&
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
@@ -629,21 +636,21 @@ function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, edi
const inverseScale = 1 / Math.max(scale, 0.05);
const Icon = nodeTypeIcon(node.type);
const maxHeaderWidth = Math.min(240, node.width * scale);
const externalHeaderWidth = node.width * scale;
return (
<div
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
style={{
width: dimensionLabel ? externalHeaderWidth : undefined,
width: dimensionLabel ? "calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))" : undefined,
maxWidth: dimensionLabel ? undefined : maxHeaderWidth,
"--canvas-node-width": `${node.width}px`,
borderRadius: "var(--r-sm)",
background: "transparent",
paddingInline: "var(--space-1-half)",
color: active ? theme.node.text : theme.node.label,
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
transformOrigin: "left bottom",
}}
} as React.CSSProperties}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -729,53 +736,93 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }
);
}
function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
function ConnectionSideRail({ side, scale, theme, visible = false, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; visible?: boolean; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
const handleRef = useRef<HTMLSpanElement>(null);
const anchorRatioRef = useRef(0.5);
const [railHovered, setRailHovered] = useState(false);
const inverseScale = 1 / Math.max(scale, 0.05);
const railSize = 80;
// Keep the control responsive to zoom, but avoid sub-pixel circles and
// strokes at far zoom levels where the plus sign appears visually off-center.
const handleSize = Math.max(20, 8 * inverseScale);
// LibTV centers the visual quick-add icon in an approximately 80px
// circular hit zone, then offsets it toward the node edge. Keep that
// visual layer separate from the real centered connection anchor.
const sideOffset = side === "left" ? 25 : -25;
const resetAnchor = useCallback(() => {
anchorRatioRef.current = 0.5;
if (handleRef.current) handleRef.current.style.top = "50%";
}, []);
const resetHandle = useCallback(() => {
if (!handleRef.current) return;
handleRef.current.style.transform = `translate(${sideOffset}px, 0) scale(1)`;
}, [sideOffset]);
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
const railBounds = event.currentTarget.getBoundingClientRect();
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
const screenPadding = Math.min(railBounds.height * 0.35, 12);
const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1)));
const anchorY = railBounds.top + railBounds.height * railRatio;
anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1)));
if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`;
const updateHandle = (event: React.PointerEvent<HTMLButtonElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const width = Math.max(bounds.width, 1);
const height = Math.max(bounds.height, 1);
const followLimit = 30;
const deltaX = event.clientX - (bounds.left + width / 2);
const deltaY = event.clientY - (bounds.top + height / 2);
const offsetScreenX = Math.max(-followLimit, Math.min(followLimit, deltaX));
const offsetScreenY = Math.max(-followLimit, Math.min(followLimit, deltaY));
const focus = 1 + Math.max(0, 1 - Math.hypot(offsetScreenX, offsetScreenY) / followLimit) * 0.1;
// While the pointer is inside the rail LibTV uses the rail center as
// the origin. The side-specific +/-25px offset is only the resting
// position used after leaving the rail.
const offsetX = offsetScreenX * inverseScale;
const offsetY = offsetScreenY * inverseScale;
if (handleRef.current) handleRef.current.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${focus})`;
};
return (
<button
type="button"
className="group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 opacity-100 outline-none transition-opacity duration-150"
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={updateAnchor}
onPointerMove={updateAnchor}
onPointerLeave={resetAnchor}
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
data-canvas-connection-rail={side}
className={`group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] flex -translate-y-1/2 items-center justify-center touch-none cursor-crosshair rounded-full outline-none transition-opacity duration-150 ${visible || railHovered ? "opacity-100" : "opacity-0"}`}
style={{ width: railSize, height: `min(100%, ${railSize}px)`, cursor: "crosshair", ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={(event) => {
setRailHovered(true);
updateHandle(event);
}}
onPointerMove={updateHandle}
onPointerLeave={() => {
setRailHovered(false);
resetHandle();
}}
onPointerDown={(event) => onPointerDown(event, 0.5)}
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
>
<span
ref={handleRef}
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
className="absolute left-1/2 top-1/2 block transition-transform duration-[80ms] ease-out group-hover:brightness-125 group-focus-visible:brightness-125"
style={{
top: "50%",
width: 18 * inverseScale,
height: 18 * inverseScale,
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
borderWidth: inverseScale,
background: theme.spatial.elevated,
borderColor: theme.node.activeStroke,
color: theme.node.activeStroke,
boxShadow: "none",
width: handleSize,
height: handleSize,
marginLeft: -handleSize / 2,
marginTop: -handleSize / 2,
transform: `translate(${sideOffset}px, 0) scale(1)`,
transition: "transform 80ms ease-out",
transformOrigin: "center",
willChange: "transform",
}}
>
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
<svg
aria-hidden="true"
className="block"
width={handleSize}
height={handleSize}
viewBox="0 0 20 20"
fill="none"
style={{
position: "absolute",
left: "50%",
top: "50%",
marginLeft: -handleSize / 2,
marginTop: -handleSize / 2,
}}
>
<circle cx="10" cy="10" r="9.35" fill={theme.spatial.elevated} />
<circle cx="10" cy="10" r="9.35" stroke={theme.node.activeStroke} strokeWidth="1.2" />
<path d="M10 6.5v7M6.5 10h7" stroke={theme.node.activeStroke} strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
</button>
);

View File

@@ -481,14 +481,21 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri
function createInlinePreview(reference: CanvasResourceReference) {
if ((reference.kind === "image" || reference.kind === "video" || reference.kind === "character") && reference.previewUrl) {
const media = document.createElement(reference.kind === "video" ? "video" : "img");
const media = document.createElement("img");
media.className = `canvas-resource-inline-preview is-${reference.kind}`;
media.setAttribute("src", reference.previewUrl);
media.setAttribute("alt", "");
if (media instanceof HTMLVideoElement) {
media.muted = true;
media.preload = "metadata";
}
return media;
}
if (reference.kind === "video" && reference.mediaUrl) {
const media = document.createElement("video");
media.className = "canvas-resource-inline-preview is-video";
media.setAttribute("src", reference.mediaUrl);
media.setAttribute("aria-hidden", "true");
media.muted = true;
media.playsInline = true;
media.preload = "metadata";
media.onloadedmetadata = () => primeVideoPreviewFrame(media);
return media;
}
const fallback = document.createElement("span");
@@ -685,7 +692,10 @@ function MentionReferenceList({ references, activeReferenceId, onSelect }: { ref
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-image" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="canvas-resource-mention-preview is-video" muted preload="metadata" />;
if (reference.kind === "video" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-video" loading="lazy" decoding="async" />;
if (reference.kind === "video" && reference.mediaUrl) {
return <video src={reference.mediaUrl} aria-hidden="true" muted playsInline preload="metadata" className="canvas-resource-mention-preview is-video" onLoadedMetadata={(event) => primeVideoPreviewFrame(event.currentTarget)} />;
}
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-character" />;
if (reference.kind === "skill") {
return (
@@ -702,6 +712,17 @@ function ReferencePreview({ reference }: { reference: CanvasResourceReference })
);
}
function primeVideoPreviewFrame(video: HTMLVideoElement) {
if (video.currentTime !== 0 || !Number.isFinite(video.duration) || video.duration <= 0) return;
try {
// Metadata-only loading does not paint a frame consistently across browsers.
// Seeking a tiny amount keeps this preview passive while forcing first-frame decode.
video.currentTime = Math.min(0.001, video.duration);
} catch {
// A transient media error should leave the fallback element usable.
}
}
function splitMentionText(value: string, references: CanvasResourceReference[]) {
if (!references.length || !value) return value ? [{ type: "text", text: value } as MentionTextPart] : [];
const referenceByToken = new Map<string, { reference: CanvasResourceReference; serializedToken: string }>();

View File

@@ -2,6 +2,7 @@ import { Button, Modal } from "antd";
import { Check, Star } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -11,14 +12,16 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
return (
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
{versions.map((node) => (
{versions.map((node) => {
const videoPreview = canvasNodeVideoPreviewUrl(node);
return (
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
</div>
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="size-full object-contain" muted preload="metadata" aria-label={node.title || "版本视频"} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : videoPreview ? <img src={videoPreview} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
</button>
<div className="space-y-2 p-3 text-[var(--fs-label)]">
<Info label="模型" value={node.metadata?.model || "默认模型"} />
@@ -27,7 +30,8 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
<Button block size="small" className="whitespace-nowrap" type={node.metadata?.versionPrimary ? "default" : "primary"} disabled={node.metadata?.versionPrimary} icon={<Star className="size-3.5" />} onClick={() => onSetPrimary(node.id)}>{node.metadata?.versionPrimary ? "当前主版本" : "设为主版本"}</Button>
</div>
</article>
))}
);
})}
</div>
</Modal>
);

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Modal, Tooltip } from "antd";
import { Image as ImageIcon, Music2, Play, UserRound } from "lucide-react";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { isStoryboardPreviewAsset } from "@/lib/canvas/canvas-storyboard-materializer";
import { resolveMediaUrl } from "@/services/file-storage";
import { CanvasNodeType, type CanvasNodeData, type StoryboardAssetBinding } from "@/types/canvas";
@@ -55,13 +56,14 @@ export function StoryboardAssetsCell({ bindings, nodes, limit = 4 }: { bindings:
}
function AssetThumbnail({ node }: { node: CanvasNodeData }) {
const source = useNodeMediaSource(node);
const videoPreview = canvasNodeVideoPreviewUrl(node);
const source = useNodeMediaSource(node.type === CanvasNodeType.Video ? null : node);
if (node.type === CanvasNodeType.Audio) return <Music2 className="size-4" />;
if (node.metadata?.workflowKind === "character" && !source) return <UserRound className="size-4" />;
if (node.type === CanvasNodeType.Video) {
return source ? (
return videoPreview ? (
<>
<video src={source} muted playsInline preload="metadata" className="size-full object-cover" aria-hidden />
<img src={videoPreview} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" />
<span className="absolute inset-0 grid place-items-center bg-black/15"><Play className="size-3.5 fill-white text-white" /></span>
</>
) : <Play className="size-4" />;

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
type ResolvedPreview = {
@@ -59,16 +58,16 @@ export function useResolvedCanvasResourceReferences(references: CanvasResourceRe
}
function previewIdentity(reference: CanvasResourceReference) {
if (!reference.storageKey || !["image", "video", "character"].includes(reference.kind)) return "";
return `${reference.kind}:${reference.storageKey}`;
const storageKey = reference.kind === "video" ? reference.previewStorageKey : reference.storageKey;
if (!storageKey || !["image", "video", "character"].includes(reference.kind)) return "";
return `${reference.kind}:${storageKey}`;
}
function resolveReferencePreview(reference: CanvasResourceReference, identity: string) {
const cached = previewPromiseCache.get(identity);
if (cached) return cached;
const pending = (reference.kind === "video"
? resolveMediaUrl(reference.storageKey, reference.previewUrl || "")
: resolveImageUrl(reference.storageKey, reference.previewUrl || "", { cacheMiss: true }))
const storageKey = reference.kind === "video" ? reference.previewStorageKey : reference.storageKey;
const pending = resolveImageUrl(storageKey, reference.previewUrl || "", { cacheMiss: true })
.catch(() => reference.previewUrl || "")
.then((url) => {
if (!url) previewPromiseCache.delete(identity);

View File

@@ -4,6 +4,20 @@
aspect-ratio: auto;
}
.canvas-video-player {
position: relative;
isolation: isolate;
--canvas-video-control-size: 36px;
--canvas-video-control-gap: 6px;
}
.canvas-video-player[data-player-variant="compact"] {
--canvas-video-control-gap: 4px;
--video-volume-panel-width: 42px;
--video-volume-slider-width: 28px;
--video-volume-slider-max-width: 48px;
}
.canvas-video-player [data-media-provider] video {
width: 100%;
height: 100%;
@@ -11,11 +25,325 @@
object-fit: contain;
}
/* 画布节点顶部还有标题和存储状态,隐藏 Vidstack 小布局的顶部控制组避免互相遮挡;全屏时恢复完整工具栏。 */
.canvas-video-player-compact:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:first-child {
/* 语义化定位:按控件内容识别组,避免依赖 Vidstack 组的首尾顺序。 */
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group {
align-items: center;
gap: var(--canvas-video-control-gap);
min-width: 0;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-slider) {
width: 100%;
padding: 0 8px;
margin: 0;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) {
display: flex;
width: 100%;
min-height: calc(var(--canvas-video-control-size) + 8px);
padding: 2px 8px 6px;
margin: 0;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-play-button,
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button,
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-volume,
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-button {
flex: 0 0 var(--canvas-video-control-size);
flex-shrink: 0;
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
margin: 0;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-play-button {
order: 1;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-time-group {
order: 2;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-volume {
order: 3;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button {
order: 4;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-time-group {
flex: 0 1 auto;
min-width: 0;
margin: 0;
white-space: nowrap;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) .vds-volume {
margin-left: auto;
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-volume) .vds-volume-popup {
top: auto;
bottom: calc(100% + var(--gap, 10px));
}
/* Keep the compact vertical volume rail centered on its mute button. The
explicit anchor prevents browser/layout scaling from inheriting a stale
horizontal offset while the popup is moved above the controls row. */
.canvas-video-player:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-volume .vds-volume-popup {
left: 50%;
right: auto;
display: grid;
place-items: center;
transform: translateX(-50%);
}
.canvas-video-player:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-volume .vds-volume-slider {
margin: 0;
}
/* The compact rail should read as a small control, not a tall black column.
Keep the hit target intact while shortening the visible rail and its panel
padding to match the LibTV proportions. */
.canvas-video-player[data-player-variant="compact"] .vds-video-layout[data-sm] .vds-volume {
--video-volume-height: 64px;
--video-volume-gap: 6px;
/* Keep the LibTV-style volume rail visually compact. The mute button's
hit target remains governed by --canvas-video-control-size; these
values only size the popup surface and slider inside it. */
--video-volume-bg: rgba(12, 13, 16, 0.92);
--video-volume-border: 1px solid rgba(255, 255, 255, 0.12);
--video-volume-border-radius: 6px;
}
.canvas-video-player[data-player-variant="compact"] .vds-video-layout[data-sm] .vds-volume .vds-volume-popup {
box-sizing: border-box;
width: var(--video-volume-panel-width);
min-width: var(--video-volume-panel-width);
padding: 5px 6px;
}
/* The popup is intentionally above the button in the canvas player. Preserve
Vidstack's safe-area behavior on that side too, so a real mouse can cross
the small visual gap without closing the popup before reaching the rail. */
.canvas-video-player[data-player-variant="compact"] .vds-video-layout[data-sm] .vds-volume .vds-volume-popup::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: calc(-1 * var(--gap, 6px));
height: var(--gap, 6px);
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"] .vds-video-layout[data-sm] .vds-volume .vds-volume-slider {
--media-slider-width: var(--video-volume-slider-width);
}
/* A confirmed silent source keeps its mute indicator as a status icon. The
volume controls remain visible for consistency, but are intentionally
unavailable so a click cannot suggest that the video gained an audio track. */
.canvas-video-player[data-no-audio="true"] .vds-volume .vds-mute-button,
.canvas-video-player[data-no-audio="true"] .vds-volume .vds-volume-slider {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
/* 画布节点保持轻量,只显示播放、时间、音量和全屏,避免窄节点中额外图标挤压布局。 */
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout .vds-controls-group > :is(.vds-caption-button, .vds-download-button, .vds-pip-button, .vds-airplay-button, .vds-google-cast-button, .vds-chapters-button) {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) {
position: absolute;
right: calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 22px);
bottom: 8px;
width: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
z-index: 50;
pointer-events: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) .vds-volume {
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) {
position: absolute;
inset-inline: 8px;
bottom: 8px;
width: auto;
min-height: var(--canvas-video-control-size);
padding: 0 0 0 calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 2px);
z-index: 2;
pointer-events: none !important;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-play-button {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button {
margin-left: auto;
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-time-group {
pointer-events: auto;
}
/* 小布局沿用 LibTV 的单行控件:播放、时间、进度、音量、全屏共享同一底边。 */
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) {
position: absolute;
left: calc(var(--canvas-video-control-size) * 3 + var(--canvas-video-control-gap) + 40px);
right: calc(var(--canvas-video-control-size) * 2 + var(--canvas-video-control-gap) * 2 + 12px);
bottom: 8px;
width: auto;
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
/* Keep the timeline above the video surface and below the fixed buttons.
The default Vidstack layout gives this group a dedicated hit layer;
absolute compact positioning must preserve that explicitly. */
z-index: 11;
pointer-events: auto !important;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 24px;
pointer-events: auto !important;
touch-action: none;
user-select: none;
cursor: pointer;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider .vds-slider-chapters,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider .vds-slider-chapter,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider .vds-slider-track,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider .vds-slider-thumb {
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) {
position: absolute;
left: 8px;
bottom: 8px;
width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
z-index: 3;
justify-content: center;
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) > .vds-controls-spacer {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) .vds-play-button {
flex: 0 0 var(--canvas-video-control-size);
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
margin: 0;
transform: none;
}
/* Vidstack switches to data-lg when a node is stretched. Keep the compact
canvas layout stable instead of allowing the large layout to reorder icons. */
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-volume):not(:has(.vds-time-group)),
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(> .vds-play-button):not(:has(.vds-time-group)) {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) {
position: absolute;
inset-inline: 8px;
bottom: 8px;
width: auto;
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
z-index: 3;
display: flex;
align-items: center;
gap: var(--canvas-video-control-gap);
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) > .vds-controls-spacer {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-play-button,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-volume,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button,
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-button {
flex: 0 0 var(--canvas-video-control-size);
flex-shrink: 0;
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
margin: 0;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-play-button {
order: 1;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-time-group {
order: 2;
flex: 0 1 auto;
min-width: 0;
margin: 0;
white-space: nowrap;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-volume {
order: 3;
margin-left: auto;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button {
order: 4;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-slider) {
position: absolute;
inset-inline: 8px;
bottom: calc(var(--canvas-video-control-size) + 12px);
width: auto;
height: 24px;
min-height: 24px;
padding: 0;
z-index: 11;
pointer-events: auto !important;
}
.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-slider) .vds-time-slider {
width: 100%;
min-width: 0;
pointer-events: auto !important;
touch-action: none;
user-select: none;
cursor: pointer;
}
/* 预览弹窗沿用完整控件,但同样固定按钮和时间区域,防止弹窗窄化时发生重叠。 */
.canvas-video-player[data-player-variant="dialog"]:not([data-fullscreen]) .vds-video-layout .vds-controls-group:has(.vds-time-group) {
padding-inline: 12px;
}
/* 节点内保持紧凑;全屏使用桌面播放器级别的控制尺寸,不依赖 Vidstack 小布局的默认值。 */
.canvas-video-player[data-fullscreen],
.canvas-video-player:fullscreen,
@@ -38,15 +366,16 @@
padding-inline: 24px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:nth-last-child(2) {
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:has(.vds-time-slider) {
min-height: 64px;
margin-bottom: -12px;
padding-inline: 28px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:last-child {
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:has(.vds-time-group) {
min-height: 82px;
gap: 4px;
margin: 0;
padding: 8px 24px max(12px, env(safe-area-inset-bottom));
}
@@ -107,8 +436,183 @@
font-size: 16px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:last-child {
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:has(.vds-time-group) {
min-height: 72px;
padding-inline: 12px;
}
}
/* The compact canvas player keeps the same control order in fullscreen as it
does inside a node: play, time, volume, fullscreen. Vidstack's small layout
renders these controls in separate groups, so pin each group explicitly
instead of relying on the group's DOM order. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) {
--canvas-video-control-size: 60px;
--canvas-video-control-gap: 8px;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group {
box-sizing: border-box;
align-items: center;
gap: var(--canvas-video-control-gap);
}
/* Only the volume control survives from the small layout's top group. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) {
position: absolute;
right: calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 24px);
bottom: max(12px, env(safe-area-inset-bottom));
width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
z-index: 50;
display: flex;
justify-content: center;
pointer-events: none;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) > :not(.vds-volume) {
display: none !important;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) .vds-volume {
flex: 0 0 var(--canvas-video-control-size);
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
pointer-events: auto;
}
/* Keep the vertical rail above the volume button in fullscreen as well. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-volume .vds-volume-popup {
top: auto;
bottom: calc(100% + var(--gap, 6px));
left: 50%;
right: auto;
transform: translateX(-50%);
box-sizing: border-box;
width: var(--video-volume-panel-width, 42px);
min-width: var(--video-volume-panel-width, 42px);
padding: 5px 6px;
}
/* The center group contains only the play button in the compact layout. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) {
position: absolute;
left: 24px;
bottom: max(12px, env(safe-area-inset-bottom));
width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
padding: 0;
z-index: 3;
display: flex;
justify-content: center;
pointer-events: auto !important;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) > .vds-controls-spacer {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) .vds-play-button {
flex: 0 0 var(--canvas-video-control-size);
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
margin: 0;
transform: none;
}
/* Time and fullscreen share the bottom row; the time text may shrink but the
fullscreen button always keeps its target size. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) {
position: absolute;
inset-inline: 24px;
bottom: max(12px, env(safe-area-inset-bottom));
width: auto;
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size);
margin: 0;
padding: 0 0 0 calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 2px);
z-index: 3;
display: flex;
align-items: center;
pointer-events: none !important;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) > .vds-controls-spacer,
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-chapter-title {
display: none;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-time-group {
order: 2;
flex: 0 1 auto;
min-width: 0;
margin: 0;
white-space: nowrap;
pointer-events: auto;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) .vds-fullscreen-button {
order: 4;
flex: 0 0 var(--canvas-video-control-size);
width: var(--canvas-video-control-size);
min-width: var(--canvas-video-control-size);
height: var(--canvas-video-control-size);
margin-left: auto;
pointer-events: auto;
}
/* Keep fullscreen controls in the same single-row order as the compact node:
play, time, progress, volume, fullscreen. The progress rail stays between
the time and volume controls instead of moving to a separate upper row. */
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) {
position: absolute;
left: calc(var(--canvas-video-control-size) * 3 + var(--canvas-video-control-gap) + 40px);
right: calc(var(--canvas-video-control-size) * 2 + var(--canvas-video-control-gap) * 2 + 28px);
bottom: max(12px, env(safe-area-inset-bottom));
width: auto;
height: var(--canvas-video-control-size);
min-height: var(--canvas-video-control-size) !important;
margin: 0;
padding: 0;
z-index: 11;
pointer-events: auto !important;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) .vds-time-slider {
width: 100%;
height: var(--canvas-video-control-size);
min-width: 0;
pointer-events: auto !important;
touch-action: none;
user-select: none;
cursor: pointer;
}
@media screen and (max-width: 599px) {
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) {
--canvas-video-control-size: 52px;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-volume) {
right: calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 12px);
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-group) {
inset-inline: 12px;
padding-left: calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 2px);
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(> .vds-play-button) {
left: 12px;
}
.canvas-video-player[data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls-group:has(.vds-time-slider) {
left: calc(var(--canvas-video-control-size) * 3 + var(--canvas-video-control-gap) + 28px);
right: calc(var(--canvas-video-control-size) * 2 + var(--canvas-video-control-gap) * 2 + 20px);
}
}

View File

@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps } from "react";
import { MediaPlayer, MediaProvider, type VideoMimeType } from "@vidstack/react";
import { isVideoProvider, MediaPlayer, MediaProvider, type MediaPlayerInstance, type VideoMimeType } from "@vidstack/react";
import { DefaultVideoLayout, defaultLayoutIcons, type DefaultLayoutTranslations } from "@vidstack/react/player/layouts/default";
import { detectVideoAudioTrack, detectVideoAudioTrackFromUrl } from "@/lib/video-poster";
import "@vidstack/react/player/styles/base.css";
import "@vidstack/react/player/styles/default/theme.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
@@ -19,6 +20,8 @@ type VideoPlayerProps = {
autoPlay?: boolean;
dataCanvasNoZoom?: boolean;
compactControls?: boolean;
/** Explicitly marks videos known to have an audio track (or not). */
hasAudio?: boolean;
onCanPlay?: MediaPlayerProps["onCanPlay"];
};
@@ -70,33 +73,170 @@ 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, onCanPlay }: VideoPlayerProps) {
const stopCanvasControlInteraction = (event: { target: EventTarget | null; stopPropagation: () => void }) => {
if (!dataCanvasNoZoom || !(event.target instanceof Element)) return;
if (event.target.closest(".vds-controls,.vds-menu-items")) event.stopPropagation();
export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, hasAudio, onCanPlay }: VideoPlayerProps) {
const [detectedHasAudio, setDetectedHasAudio] = useState<boolean | undefined>(undefined);
const autoPlayAttemptedRef = useRef(false);
const audioProbeGenerationRef = useRef(0);
const mediaPlayerRef = useRef<MediaPlayerInstance>(null);
// Match LibTV's conservative rule: only explicit/container-confirmed
// silence mutes the player. Runtime probes are used to confirm audio and
// correct stale persisted `false`, but a negative/unknown probe never
// forces an otherwise-unknown video into the muted state.
// A conclusive container probe wins over stale persisted metadata in
// either direction. Browser runtime probes remain conservative when they
// cannot expose an audio-track list.
const effectiveHasAudio = detectedHasAudio === false ? false : hasAudio === true || detectedHasAudio === true ? true : hasAudio === false ? false : undefined;
const noAudio = effectiveHasAudio === false;
const layoutIcons = useMemo(() => {
if (!noAudio) return defaultLayoutIcons;
// Vidstack chooses the volume icon from its internal media state. That
// state can briefly lag behind an explicit `hasAudio: false` prop, so
// keep all volume variants visually muted for known silent videos.
return {
...defaultLayoutIcons,
MuteButton: {
...defaultLayoutIcons.MuteButton,
VolumeLow: defaultLayoutIcons.MuteButton.Mute,
VolumeHigh: defaultLayoutIcons.MuteButton.Mute,
},
};
}, [noAudio]);
useEffect(() => {
setDetectedHasAudio(undefined);
autoPlayAttemptedRef.current = false;
audioProbeGenerationRef.current += 1;
}, [src]);
useEffect(() => {
const player = mediaPlayerRef.current?.el;
if (!player) return;
// Vidstack exposes these controls as custom elements. Setting their
// disabled state after layout creation keeps both pointer and keyboard
// access consistent when an audio-track probe resolves asynchronously.
const muteButton = player.querySelector<HTMLButtonElement>(".vds-mute-button");
if (muteButton) {
muteButton.disabled = noAudio;
muteButton.setAttribute("aria-disabled", String(noAudio));
}
const volumeSlider = player.querySelector<HTMLElement>(".vds-volume-slider");
if (volumeSlider) {
(volumeSlider as HTMLElement & { disabled?: boolean }).disabled = noAudio;
volumeSlider.setAttribute("aria-disabled", String(noAudio));
}
}, [noAudio]);
const probeRemoteAudioTrack = () => {
if (hasAudio === false) return;
const generation = audioProbeGenerationRef.current;
void detectVideoAudioTrackFromUrl(src).then((detected) => {
if (generation === audioProbeGenerationRef.current && detected !== undefined) setDetectedHasAudio(detected);
});
};
const stopCanvasControlInteraction = (event: { target: EventTarget | null; nativeEvent?: Event; stopPropagation: () => void; preventDefault?: () => void }) => {
if (!isPlayerControlEvent(event)) return;
if (noAudio && isVolumeControlEvent(event)) {
event.preventDefault?.();
event.stopPropagation();
return;
}
if (dataCanvasNoZoom) event.stopPropagation();
};
const stopCanvasControlClick = (event: React.MouseEvent) => {
if (!isPlayerControlEvent(event)) return;
if (noAudio && isVolumeControlEvent(event)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (dataCanvasNoZoom) event.stopPropagation();
};
const type = mimeType && supportedVideoMimeTypes.has(mimeType as VideoMimeType) ? (mimeType as VideoMimeType) : "video/mp4";
const mediaSource = useMemo(() => ({ src, type }), [src, type]);
const handleCanPlay = (detail: Parameters<NonNullable<MediaPlayerProps["onCanPlay"]>>[0], event: Parameters<NonNullable<MediaPlayerProps["onCanPlay"]>>[1]) => {
const provider = event.target.provider;
const media = isVideoProvider(provider) ? provider.media : undefined;
const detected = media ? detectVideoAudioTrack(media) : undefined;
if (detected !== undefined) setDetectedHasAudio(detected);
else probeRemoteAudioTrack();
// `canplay` may fire again after buffering or seeking. Only the first
// event may satisfy the activation autoplay intent; later events must
// not override a user pause.
if (autoPlay && !autoPlayAttemptedRef.current) {
autoPlayAttemptedRef.current = true;
void event.target.play().catch(() => undefined);
}
onCanPlay?.(detail, event);
};
return (
<MediaPlayer
className={`canvas-video-player ${compactControls ? "canvas-video-player-compact" : ""} ${className || ""}`}
data-player-variant={compactControls ? "compact" : "dialog"}
ref={mediaPlayerRef}
src={mediaSource}
title={title}
viewType="video"
streamType="on-demand"
playsInline
autoPlay={autoPlay}
muted={noAudio ? true : undefined}
load="eager"
preload={preload}
data-canvas-no-zoom={dataCanvasNoZoom ? "true" : undefined}
data-no-audio={noAudio ? "true" : undefined}
style={{ "--video-brand": brandColor }}
onCanPlay={onCanPlay}
onCanPlay={handleCanPlay}
onLoadedMetadata={(event) => {
const provider = event.target.provider;
const media = isVideoProvider(provider) ? provider.media : undefined;
const detected = media ? detectVideoAudioTrack(media) : undefined;
if (detected !== undefined) setDetectedHasAudio(detected);
else probeRemoteAudioTrack();
}}
onLoadedData={(event) => {
const provider = event.target.provider;
const media = isVideoProvider(provider) ? provider.media : undefined;
const detected = media ? detectVideoAudioTrack(media) : undefined;
if (detected !== undefined) setDetectedHasAudio(detected);
else probeRemoteAudioTrack();
}}
onPointerDown={stopCanvasControlInteraction}
onMouseDown={stopCanvasControlInteraction}
onClick={stopCanvasControlClick}
onKeyDown={stopCanvasControlInteraction}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} translations={zhCNTranslations} />
<DefaultVideoLayout
icons={layoutIcons}
translations={zhCNTranslations}
// Canvas controls use one stable DOM arrangement regardless
// of the node's aspect ratio. Without this override Vidstack
// swaps between its small and large layouts as the node is
// resized, moving the same icons to different groups.
smallLayoutWhen={compactControls ? true : undefined}
// The canvas selects and mounts a player from the node's
// pointer event. Vidstack's default pointerup gesture can
// otherwise toggle the freshly mounted player a second time,
// causing the intermittent play-then-pause-at-zero bug.
noGestures={dataCanvasNoZoom}
slots={{
// 节点外部已经显示标题,播放器内不再重复渲染章节标题和设置菜单。
chapterTitle: null,
settingsMenu: null,
}}
/>
</MediaPlayer>
);
}
function isPlayerControlEvent(event: { target: EventTarget | null; nativeEvent?: Event }) {
if (event.target instanceof Element && event.target.closest(".vds-controls,.vds-menu-items")) return true;
return event.nativeEvent?.composedPath?.().some((item) => item instanceof Element && Boolean(item.closest(".vds-controls,.vds-menu-items"))) || false;
}
function isVolumeControlEvent(event: { target: EventTarget | null; nativeEvent?: Event }) {
if (event.target instanceof Element && event.target.closest(".vds-volume,.vds-mute-button,.vds-volume-slider")) return true;
return event.nativeEvent?.composedPath?.().some((item) => item instanceof Element && Boolean(item.closest(".vds-volume,.vds-mute-button,.vds-volume-slider"))) || false;
}