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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
133
web/src/components/canvas/canvas-audio-player.tsx
Normal file
133
web/src/components/canvas/canvas-audio-player.tsx
Normal 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")}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user