Your Name 3101f5966d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: enhance canvas agent UI customization
2026-04-23 13:25:05 +08:00

396 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useMemo, useRef, useState } from "react";
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Slider } from "@/components/ui/slider";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { toast } from "sonner";
import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface VideoNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
export function VideoNode({
node,
isSelected,
canvasToolbarConfig,
}: VideoNodeProps) {
const data = node.data as VideoNodeData;
const videoUploadItem = getCanvasToolbarItem(canvasToolbarConfig, "videoUpload");
const videoPlayItem = getCanvasToolbarItem(canvasToolbarConfig, "videoPlay");
const videoPauseItem = getCanvasToolbarItem(canvasToolbarConfig, "videoPause");
const videoMuteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoMute");
const videoUnmuteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoUnmute");
const videoMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "videoMore");
const videoDownloadItem = getCanvasToolbarItem(canvasToolbarConfig, "videoDownload");
const videoDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoDelete");
const contentScale = useMemo(
() =>
getNodeContentScale(node.size, {
width: 240,
height: 220,
}),
[node.size.height, node.size.width]
);
const scaledContentStyle = useMemo(
() => getScaledNodeContentStyle(contentScale),
[contentScale]
);
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
const videoRef = useRef<HTMLVideoElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const videoPlaybackItem = isPlaying ? videoPauseItem : videoPlayItem;
const videoAudioItem = isMuted ? videoUnmuteItem : videoMuteItem;
const [progress, setProgress] = useState(0);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
// 处理视频上传 - 上传到服务器获取 URL
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 检查文件大小(限制 100MB)
if (file.size > 100 * 1024 * 1024) {
toast.error("视频文件不能超过 100MB");
return;
}
// 更新状态为上传中
updateNode(node.id, {
data: { ...data, status: 'generating' },
});
try {
// 上传视频到服务器
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/video/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const result = await response.json();
// 使用服务器返回的 URL
updateNode(node.id, {
data: { ...data, src: result.url, status: 'completed' },
});
toast.success('视频上传成功');
} catch (error) {
console.error('视频上传失败:', error);
toast.error('视频上传失败,请重试');
updateNode(node.id, {
data: { ...data, status: 'failed' },
});
}
};
// 播放/暂停
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// 静音/取消静音
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
// 更新进度
const handleTimeUpdate = () => {
if (videoRef.current) {
const progress = (videoRef.current.currentTime / videoRef.current.duration) * 100;
setProgress(progress);
}
};
// 跳转进度
const handleSeek = (value: number[]) => {
if (videoRef.current) {
const time = (value[0] / 100) * videoRef.current.duration;
videoRef.current.currentTime = time;
setProgress(value[0]);
}
};
// 下载视频
const handleDownload = () => {
if (data.src) {
const link = document.createElement('a');
link.href = data.src;
link.download = `video-${node.id}.mp4`;
link.click();
}
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-[24px] border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative h-full w-full" style={scaledContentStyle}>
{/* 视频内容 */}
{data.src ? (
<div className="relative w-full h-full">
<video
ref={videoRef}
src={data.src}
className="h-full w-full bg-slate-950 object-cover"
muted={isMuted}
loop
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
/>
{/* 播放控制层 */}
{isHovered && (
<div className="absolute inset-0 flex flex-col justify-end">
{/* 中央播放按钮 */}
<Button
variant="ghost"
size="icon"
className="absolute left-1/2 top-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-white text-[#16181d] shadow-[0_12px_26px_rgba(0,0,0,0.28)] hover:bg-white"
onClick={togglePlay}
title={videoPlaybackItem.label}
>
{isPlaying ? (
<CanvasToolbarIcon
className="h-6 w-6"
fallback={Pause}
item={videoPauseItem}
/>
) : (
<CanvasToolbarIcon
className="ml-1 h-6 w-6"
fallback={Play}
item={videoPlayItem}
/>
)}
</Button>
{/* 底部控制栏 */}
<div className="m-3 space-y-2 rounded-[20px] border border-[#dbe2ee] bg-white p-3 shadow-[0_0_0_1px_rgba(219,226,238,0.26)]">
{/* 进度条 */}
<Slider
value={[progress]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="cursor-pointer"
/>
{/* 控制按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
onClick={togglePlay}
title={videoPlaybackItem.label}
>
{isPlaying ? (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Pause}
item={videoPauseItem}
/>
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Play}
item={videoPlayItem}
/>
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
onClick={toggleMute}
title={videoAudioItem.label}
>
{isMuted ? (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={VolumeX}
item={videoUnmuteItem}
/>
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Volume2}
item={videoMuteItem}
/>
)}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
) : data.status === 'generating' ? (
<div className="flex h-full w-full flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center select-none dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none text-sm font-medium text-[#3f4b63]">生成中...</span>
{data.progress && (
<span className="mt-1 text-xs text-[#7a8395] select-none">{data.progress}%</span>
)}
</div>
) : data.status === 'failed' ? (
<div className="flex h-full w-full flex-col items-center justify-center bg-[#fff7f8] px-6 text-center select-none dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] select-none">生成失败</span>
</div>
) : (
<label className="flex h-full w-full cursor-pointer flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center transition-colors select-none hover:bg-[#f2f5fb] dark:bg-zinc-900 dark:hover:bg-zinc-800">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Video}
item={videoUploadItem}
/>
</span>
<span className="text-sm font-medium text-[#3f4b63] select-none">
{videoUploadItem.label}
</span>
<span className="mt-1 text-xs text-[#7a8395] select-none">添加视频素材、参考视频或结果片段</span>
<input
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoUpload}
/>
</label>
)}
{/* 悬浮操作栏 */}
{isHovered && data.src && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full border border-[#dbe2ee] bg-white text-[#5d687b] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] hover:bg-[#f7f9ff] hover:text-[#16181d]"
title={videoMoreItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={videoMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownload}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={videoDownloadItem}
/>
{videoDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={videoDeleteItem}
/>
{videoDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{shortAgentExecutionSnapshotId ? (
<button
type="button"
onClick={() => setSnapshotDialogOpen(true)}
className="absolute top-2 left-2 inline-flex items-center gap-1 rounded-full border border-[#dbe2ee] bg-white px-2.5 py-1 text-[11px] text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]"
title={`执行快照:${agentExecutionSnapshotId}`}
>
快照 {shortAgentExecutionSnapshotId}
</button>
) : null}
<AgentExecutionSnapshotDialog
snapshotId={agentExecutionSnapshotId || null}
open={snapshotDialogOpen}
onOpenChange={setSnapshotDialogOpen}
/>
{/* 提示词显示 */}
{data.prompt && isHovered && (
<div className="absolute bottom-3 left-3 right-3 rounded-[16px] border border-[#dbe2ee] bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(219,226,238,0.26)]">
<p className="line-clamp-2 text-xs leading-5 text-[#556072]">{data.prompt}</p>
</div>
)}
</div>
</div>
);
}