299 lines
9.9 KiB
TypeScript
299 lines
9.9 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef } from "react";
|
||
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-react";
|
||
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 { useCanvasStore } from "@/store/canvas";
|
||
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface VideoNodeProps {
|
||
node: CanvasNode;
|
||
isSelected: boolean;
|
||
}
|
||
|
||
export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
||
const data = node.data as VideoNodeData;
|
||
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 [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 w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
|
||
isSelected && "border-primary"
|
||
)}
|
||
onMouseEnter={() => setIsHovered(true)}
|
||
onMouseLeave={() => setIsHovered(false)}
|
||
>
|
||
{/* 视频内容 */}
|
||
{data.src ? (
|
||
<div className="relative w-full h-full">
|
||
<video
|
||
ref={videoRef}
|
||
src={data.src}
|
||
className="w-full h-full object-cover"
|
||
muted={isMuted}
|
||
loop
|
||
onTimeUpdate={handleTimeUpdate}
|
||
onEnded={() => setIsPlaying(false)}
|
||
/>
|
||
|
||
{/* 播放控制层 */}
|
||
{isHovered && (
|
||
<div className="absolute inset-0 bg-black/30 flex flex-col justify-end">
|
||
{/* 中央播放按钮 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full bg-black/50 hover:bg-black/70 text-white"
|
||
onClick={togglePlay}
|
||
>
|
||
{isPlaying ? (
|
||
<Pause className="h-6 w-6" />
|
||
) : (
|
||
<Play className="h-6 w-6 ml-1" />
|
||
)}
|
||
</Button>
|
||
|
||
{/* 底部控制栏 */}
|
||
<div className="p-3 space-y-2">
|
||
{/* 进度条 */}
|
||
<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-white hover:bg-white/20"
|
||
onClick={togglePlay}
|
||
>
|
||
{isPlaying ? (
|
||
<Pause className="h-4 w-4" />
|
||
) : (
|
||
<Play className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-white hover:bg-white/20"
|
||
onClick={toggleMute}
|
||
>
|
||
{isMuted ? (
|
||
<VolumeX className="h-4 w-4" />
|
||
) : (
|
||
<Volume2 className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : data.status === 'generating' ? (
|
||
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
||
<span className="text-sm text-muted-foreground select-none">生成中...</span>
|
||
{data.progress && (
|
||
<span className="text-xs text-muted-foreground mt-1 select-none">{data.progress}%</span>
|
||
)}
|
||
</div>
|
||
) : data.status === 'failed' ? (
|
||
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10 select-none">
|
||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||
<span className="text-sm text-destructive select-none">生成失败</span>
|
||
</div>
|
||
) : (
|
||
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors select-none">
|
||
<Video className="h-8 w-8 text-muted-foreground mb-2" />
|
||
<span className="text-sm text-muted-foreground 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-7 w-7">
|
||
<MoreVertical className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={handleDownload}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-destructive"
|
||
onClick={() => deleteNode(node.id)}
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
删除
|
||
</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 bg-black/70 px-2 py-1 text-[11px] text-white shadow-sm transition-colors hover:bg-black/80"
|
||
title={`执行快照:${agentExecutionSnapshotId}`}
|
||
>
|
||
快照 {shortAgentExecutionSnapshotId}
|
||
</button>
|
||
) : null}
|
||
<AgentExecutionSnapshotDialog
|
||
snapshotId={agentExecutionSnapshotId || null}
|
||
open={snapshotDialogOpen}
|
||
onOpenChange={setSnapshotDialogOpen}
|
||
/>
|
||
|
||
{/* 提示词显示 */}
|
||
{data.prompt && isHovered && (
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|