feat: add video node preview

This commit is contained in:
stupid-h4er
2026-05-26 23:10:49 +08:00
parent c9fea71821
commit 1b2d473d9c
8 changed files with 137 additions and 24 deletions
@@ -18,7 +18,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from ".
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number };
inputSummary: { textCount: number; imageCount: number; videoCount: number };
inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void;
@@ -41,6 +41,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image");
const videoInputs = inputs.filter((input) => input.type === "video");
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
@@ -112,6 +113,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
<Eye className="size-3.5" />
预览
@@ -130,7 +132,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount)}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
>
@@ -171,6 +173,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</div>
</PreviewSection>
</div>
<div className="shrink-0">
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{videoInputs.map((input, index) => (
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
@@ -288,6 +299,31 @@ function ImageSortCard({
);
}
function VideoSortCard({
input,
videoIndex,
videoTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
videoIndex: number;
videoTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.video) return null;
return (
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{videoIndex + 1}</span>
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<>
@@ -1,12 +1,15 @@
import type { ChatCompletionMessage } from "@/services/api/image";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type NodeGenerationContext = {
prompt: string;
referenceImages: ReferenceImage[];
referenceVideos: ReferenceVideo[];
textCount: number;
imageCount: number;
videoCount: number;
};
export type NodeGenerationInput = {
@@ -15,6 +18,7 @@ export type NodeGenerationInput = {
title: string;
text?: string;
image?: ReferenceImage;
video?: ReferenceVideo;
};
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
@@ -24,12 +28,15 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
.filter(Boolean)
.join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = inputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages,
referenceVideos,
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
};
}
@@ -37,6 +44,8 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
@@ -77,6 +86,17 @@ function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
};
}
function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
if (node.type !== CanvasNodeType.Video || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp4`,
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content,
storageKey: node.metadata.storageKey,
};
}
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections
@@ -466,7 +466,7 @@ function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" />
<span className="text-sm">视频节点(未开发完,请勿使用)</span>
<span className="text-sm">空视频节点</span>
</div>
);
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;