Your Name c7ffcda87b
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add infinite canvas feature with AI image generation
- Add canvas project and node database tables
- Create canvas workspace with drag, resize, zoom and pan
- Add ImageNode with background removal, enhance, extend, angle edit, scene fusion
- Add VideoNode with playback controls
- Add TextNode for text editing
- Integrate AI image generation API (Gemini/Alibaba)
- Implement viewport culling for performance optimization
- Add canvas entry in top navigation bar
2026-01-24 19:48:13 +08:00

248 lines
8.1 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, { 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 { 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 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 updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
// 处理视频上传
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 检查文件大小(限制 100MB)
if (file.size > 100 * 1024 * 1024) {
toast.error("视频文件不能超过 100MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
updateNode(node.id, {
data: { ...data, src, status: 'completed' },
});
};
reader.readAsDataURL(file);
};
// 播放/暂停
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">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
{data.progress && (
<span className="text-xs text-muted-foreground mt-1">{data.progress}%</span>
)}
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</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">
<Video className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">点击上传视频</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>
)}
{/* 提示词显示 */}
{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>
);
}