feat(canvas): 视频节点 - 接入 Vidstack 播放器并收敛任务查询范围

This commit is contained in:
ddCat
2026-07-31 20:19:12 +08:00
parent 14da5e8a19
commit 81868922c6
8 changed files with 144 additions and 12 deletions

View File

@@ -23,6 +23,7 @@
"@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"@uiw/react-codemirror": "^4.25.11",
"@vidstack/react": "1.15.6",
"antd": "^6.5.1",
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",
@@ -821,6 +822,8 @@
"@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
"@vidstack/react": ["@vidstack/react@1.15.6", "", { "dependencies": { "@floating-ui/dom": "^1.6.10", "media-captions": "^1.0.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-hO7cLld5yfxCdDnDCYqh5aS7JxmHfsmX43PmKBJWzDTwK40P3hh5qAdDQzl3qDtEJLYjuo3Sv+kr6tt/VRtJzQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
@@ -1327,6 +1330,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"media-captions": ["media-captions@1.0.4", "", {}, "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w=="],
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],

View File

@@ -29,6 +29,7 @@
"@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"@uiw/react-codemirror": "^4.25.11",
"@vidstack/react": "1.15.6",
"antd": "^6.5.1",
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",

View File

@@ -17,6 +17,7 @@ import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canv
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { VideoPlayer } from "@/components/video-player";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
@@ -883,14 +884,8 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
}
function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const playWhenReadyRef = useRef(false);
const { url, loading, load } = useNodeResourceUrl(node, false);
useEffect(() => {
if (!url || !playWhenReadyRef.current) return;
playWhenReadyRef.current = false;
void videoRef.current?.play().catch(() => undefined);
}, [url]);
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
@@ -901,7 +896,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRender
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 <video ref={videoRef} src={url} controls preload={reduceMediaEffects ? "none" : "metadata"} className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
return <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-[18px] bg-black" dataCanvasNoZoom compactControls />;
}
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {

View File

@@ -0,0 +1,17 @@
.canvas-video-player [data-media-provider] {
width: 100%;
height: 100%;
aspect-ratio: auto;
}
.canvas-video-player [data-media-provider] video {
width: 100%;
height: 100%;
aspect-ratio: auto;
object-fit: contain;
}
/* 画布节点顶部还有标题和存储状态,隐藏 Vidstack 小布局的顶部控制组避免互相遮挡。 */
.canvas-video-player-compact .vds-video-layout[data-sm] .vds-controls-group:first-child {
display: none;
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from "react";
import type { CSSProperties, ComponentProps } from "react";
import { MediaPlayer, MediaProvider, type VideoMimeType } from "@vidstack/react";
import { DefaultVideoLayout, defaultLayoutIcons, type DefaultLayoutTranslations } from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/base.css";
import "@vidstack/react/player/styles/default/theme.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "./video-player.css";
type MediaPlayerProps = ComponentProps<typeof MediaPlayer>;
type VideoPlayerProps = {
src: string;
mimeType?: string;
title?: string;
className?: string;
brandColor?: string;
preload?: MediaPlayerProps["preload"];
autoPlay?: boolean;
dataCanvasNoZoom?: boolean;
compactControls?: boolean;
onCanPlay?: MediaPlayerProps["onCanPlay"];
};
const zhCNTranslations = {
Accessibility: "辅助功能",
AirPlay: "隔空播放",
Audio: "音频",
Auto: "自动",
Boost: "音量增强",
Captions: "字幕",
"Caption Styles": "字幕样式",
Chapters: "章节",
"Closed-Captions Off": "关闭字幕",
"Closed-Captions On": "开启字幕",
Connected: "已连接",
Connecting: "连接中",
Default: "默认",
Disabled: "已禁用",
Disconnected: "已断开",
Download: "下载",
"Enter Fullscreen": "进入全屏",
"Enter PiP": "进入画中画",
"Exit Fullscreen": "退出全屏",
"Exit PiP": "退出画中画",
Fullscreen: "全屏",
Loop: "循环播放",
Mute: "静音",
Normal: "正常",
Off: "关闭",
Pause: "暂停",
Play: "播放",
Playback: "播放",
PiP: "画中画",
Quality: "画质",
Replay: "重新播放",
Reset: "重置",
Seek: "跳转",
"Seek Backward": "快退",
"Seek Forward": "快进",
Settings: "设置",
Speed: "倍速",
Unmute: "取消静音",
Volume: "音量",
} satisfies Partial<DefaultLayoutTranslations>;
const supportedVideoMimeTypes = new Set<VideoMimeType>(["video/mp4", "video/webm", "video/3gp", "video/ogg", "video/avi", "video/mpeg", "video/object"]);
/**
* 统一视频播放表面,保留原生媒体 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();
};
const type = mimeType && supportedVideoMimeTypes.has(mimeType as VideoMimeType) ? (mimeType as VideoMimeType) : "video/mp4";
const mediaSource = useMemo(() => ({ src, type }), [src, type]);
return (
<MediaPlayer
className={`canvas-video-player ${compactControls ? "canvas-video-player-compact" : ""} ${className || ""}`}
src={mediaSource}
title={title}
viewType="video"
streamType="on-demand"
playsInline
autoPlay={autoPlay}
load="eager"
preload={preload}
data-canvas-no-zoom={dataCanvasNoZoom ? "true" : undefined}
style={{ "--video-brand": brandColor } as CSSProperties}
onCanPlay={onCanPlay}
onPointerDown={stopCanvasControlInteraction}
onMouseDown={stopCanvasControlInteraction}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} translations={zhCNTranslations} />
</MediaPlayer>
);
}

View File

@@ -3,6 +3,7 @@ import { Button, Image, Modal } from "antd";
import { TaskDetailItem, taskStatusText } from "./canvas-project-feedback";
import type { GenerationTask, TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { VideoPlayer } from "@/components/video-player";
type CanvasProjectStatusDialogsProps = {
theme: { node: { stroke: string; panel: string; muted: string; fill: string } };
@@ -48,7 +49,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</Modal>
<Modal title="视频预览" open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)} centered onCancel={onClosePreview} footer={null} width="min(1200px, calc(100vw - 32px))" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? <video src={previewNode.metadata.content} controls className="max-h-[84vh] max-w-full bg-black object-contain" /> : null}
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? <VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} className="max-h-[84vh] max-w-full bg-black" /> : null}
</Modal>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (

View File

@@ -140,15 +140,26 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
});
}, [projectId, setNodes]);
// 调度只补齐后台返回的用户级活跃任务空位,最终并发仍由后端原子校验。
// 仅查询当前画布的活跃任务来安排批次,跨画布并发仍由后端创建任务时原子校验。
const scheduleWaitingItems = useCallback(async () => {
if (!projectLoaded || schedulingRef.current) return;
schedulingRef.current = true;
try {
const tasks = await listGenerationTasks(100).catch(() => null);
const currentNodes = nodesRef.current;
// 没有当前画布的等待项时无需查询任务中心,避免空画布持续轮询。
const hasWaitingItems = currentNodes.some((sourceNode) =>
(sourceNode.metadata?.generationBatches || []).some((batch) =>
batch.projectId === projectId &&
batch.status !== "completed" &&
batch.status !== "cancelled" &&
batch.items.some((item) => item.status === "waiting"),
),
);
if (!hasWaitingItems) return;
const tasks = await listGenerationTasks(100, { projectId, activeOnly: true }).catch(() => null);
if (!tasks) return;
const activeTaskCount = tasks.filter((task) => task.status === "queued" || task.status === "running").length;
const currentNodes = nodesRef.current;
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
const pendingReservations = [...controllersRef.current.keys()].filter((key) => {
const [, itemId] = key.split(":");

View File

@@ -165,7 +165,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const taskIds = Array.from(new Set(recoveryNodes.map((node) => node.metadata?.taskId).filter((id): id is string => Boolean(id))));
const tasks = (await Promise.all(taskIds.map((id) => queryGenerationTask(id).catch(() => undefined)))).filter((task): task is GenerationTask => Boolean(task));
if (recoveryNodes.some((node) => !node.metadata?.taskId)) {
const recentTasks = await listGenerationTasks(30).catch(() => []);
const recentTasks = await listGenerationTasks(30, { projectId }).catch(() => []);
tasks.push(...recentTasks.filter((task) => !tasks.some((item) => item.id === task.id)));
}
const projectTasks = tasks.filter((task) => task.projectId === projectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"));