feat: add video node preview
This commit is contained in:
@@ -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 />;
|
||||
|
||||
Reference in New Issue
Block a user