forked from 77media/video-flow
897 lines
39 KiB
TypeScript
897 lines
39 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from 'react';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Slider } from '@/components/ui/slider';
|
||
import { ArrowLeft, ArrowRight, Play, Trash2, Replace, Scissors, Volume2, Edit, Upload, Image, Sparkles, ChevronLeft, ChevronRight, Layers, Pause, File, Ruler, UnfoldHorizontal, RefreshCcw, RotateCcw } from 'lucide-react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Input } from '@/components/ui/input';
|
||
|
||
interface GenerateShotsStepProps {
|
||
onNext: () => void;
|
||
onPrevious: () => void;
|
||
}
|
||
|
||
const mockChapters = [
|
||
{
|
||
id: 1,
|
||
title: 'Chapter 1',
|
||
shots: [
|
||
{
|
||
id: '1-1',
|
||
type: 'talking-head',
|
||
duration: 8,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Opening welcome shot with character',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 1,
|
||
},
|
||
{
|
||
id: '1-2',
|
||
type: 'b-roll',
|
||
duration: 9,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Technology overview montage',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 2,
|
||
}
|
||
],
|
||
},
|
||
{
|
||
id: 2,
|
||
title: 'Chapter 2',
|
||
shots: [
|
||
{
|
||
id: '2-1',
|
||
type: 'talking-head',
|
||
duration: 8,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Opening welcome shot with character',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 1,
|
||
},
|
||
{
|
||
id: '2-2',
|
||
type: 'b-roll',
|
||
duration: 9,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Technology overview montage',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 2,
|
||
},
|
||
{
|
||
id: '2-3',
|
||
type: 'animation',
|
||
duration: 10,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Animation sequence',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 3,
|
||
},
|
||
{
|
||
id: '2-4',
|
||
type: 'talking-head',
|
||
duration: 8,
|
||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
generatedVideos:[
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
],
|
||
description: 'Character dialogue',
|
||
transition: 'Selected Automatically by Preset',
|
||
volume: 55,
|
||
mediaNumber: 4,
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
const transitionTypes = [
|
||
{ value: 'fade', label: 'Fade' },
|
||
{ value: 'slide', label: 'Slide' },
|
||
{ value: 'zoom', label: 'Zoom' },
|
||
{ value: 'cut', label: 'Cut' },
|
||
];
|
||
|
||
const replaceMediaTabs = [
|
||
{ value: 'uploaded', label: 'Uploaded media' },
|
||
{ value: 'stock', label: 'Stock media' },
|
||
{ value: 'generative', label: 'Generative media' },
|
||
];
|
||
|
||
const mediaPropertyTabs = [
|
||
{ value: 'media', label: 'Media' },
|
||
{ value: 'audio', label: 'Audio & SFX' },
|
||
];
|
||
|
||
export function GenerateShotsStep({ onNext, onPrevious }: GenerateShotsStepProps) {
|
||
const [selectedChapter, setSelectedChapter] = useState(1);
|
||
const [selectedShot, setSelectedShot] = useState('1-1');
|
||
const [chapters, setChapters] = useState(mockChapters);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const currentChapter = chapters.find(ch => ch.id === selectedChapter);
|
||
const currentShot = currentChapter?.shots.find(shot => shot.id === selectedShot);
|
||
const [isCheckVideoOpen, setIsCheckVideoOpen] = useState(false);
|
||
const [isReplaceMediaOpen, setIsReplaceMediaOpen] = useState(false);
|
||
const [isMediaPropertyOpen, setIsMediaPropertyOpen] = useState(false);
|
||
const [activeTabReplaceMedia, setActiveTabReplaceMedia] = useState('uploaded');
|
||
const [activeTabMediaProperty, setActiveTabMediaProperty] = useState('media');
|
||
|
||
const handlePlayPause = () => {
|
||
if (videoRef.current) {
|
||
if (videoRef.current.paused) {
|
||
videoRef.current.play();
|
||
setIsPlaying(true);
|
||
} else {
|
||
videoRef.current.pause();
|
||
setIsPlaying(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTransitionChange = (shotId: string, transition: string) => {
|
||
setChapters(chapters.map(ch =>
|
||
ch.id === selectedChapter
|
||
? {
|
||
...ch,
|
||
shots: ch.shots.map(shot =>
|
||
shot.id === shotId ? { ...shot, transition } : shot
|
||
)
|
||
}
|
||
: ch
|
||
));
|
||
};
|
||
|
||
const handleVolumeChange = (shotId: string, volume: number[]) => {
|
||
setChapters(chapters.map(ch =>
|
||
ch.id === selectedChapter
|
||
? {
|
||
...ch,
|
||
shots: ch.shots.map(shot =>
|
||
shot.id === shotId ? { ...shot, volume: volume[0] } : shot
|
||
)
|
||
}
|
||
: ch
|
||
));
|
||
};
|
||
|
||
const handleDeleteShot = (shotId: string) => {
|
||
setChapters(chapters.map(ch =>
|
||
ch.id === selectedChapter
|
||
? {
|
||
...ch,
|
||
shots: ch.shots.filter(shot => shot.id !== shotId)
|
||
}
|
||
: ch
|
||
));
|
||
};
|
||
|
||
const handleOpenReplaceMedia = (tab: string) => {
|
||
setActiveTabReplaceMedia(tab);
|
||
setIsReplaceMediaOpen(true);
|
||
};
|
||
|
||
const handleOpenMediaProperty = (tab: string) => {
|
||
setActiveTabMediaProperty(tab);
|
||
setIsMediaPropertyOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen text-white">
|
||
{/* 分镜视频列表 弹窗 */}
|
||
<Dialog open={isCheckVideoOpen} onOpenChange={setIsCheckVideoOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Media history</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-4">
|
||
{currentShot?.generatedVideos.map((video, index) => (
|
||
<div key={index} className={`flex items-center justify-between ${currentShot?.shotVideo === video ? 'border-2 border-blue-500' : 'border-gray-600'}`}>
|
||
<video src={video} className="w-full h-full object-cover" autoPlay={false} muted={false} loop={false} controls />
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Apply 按钮 Cancel 按钮 */}
|
||
<div className="flex justify-end gap-4">
|
||
<Button variant="outline">Cancel</Button>
|
||
<Button>Apply</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
{/* 替换媒体 弹窗;点击弹窗以外 不触发关闭弹窗 */}
|
||
<Dialog open={isReplaceMediaOpen} onOpenChange={setIsReplaceMediaOpen}>
|
||
<DialogContent className="max-w-4xl w-full h-full overflow-y-auto p-0">
|
||
<DialogHeader className="fixed top-0 left-5 right-10 z-10 h-10 flex justify-center">
|
||
<DialogTitle>Replace media</DialogTitle>
|
||
</DialogHeader>
|
||
{/* 占剩余高度 溢出滚动 */}
|
||
<div className="flex flex-col gap-4 h-[calc(100vh-7rem)] overflow-y-auto mt-5 hide-scrollbar p-5">
|
||
{/* 章节列表 */}
|
||
<div>
|
||
{chapters.map((chapter, index) => (
|
||
<div key={chapter.id} className="flex items-center justify-between">
|
||
{/* Chapter: index + 1 文字竖着显示 */}
|
||
<div className="text-sm text-gray-300" style={{
|
||
transform: 'rotate(180deg)',
|
||
whiteSpace: 'nowrap',
|
||
height: 'fit-content',
|
||
writingMode: 'vertical-lr',
|
||
marginRight: '5px'
|
||
}}>
|
||
<div>Chapter {chapter.id}</div>
|
||
</div>
|
||
{/* flex: 1 */}
|
||
<div className="flex flex-1 space-x-2 overflow-x-auto pb-2">
|
||
{chapter.shots.map((shot, index) => (
|
||
<div
|
||
key={shot.id}
|
||
className={`relative flex-shrink-0 cursor-pointer rounded-lg overflow-hidden border-2 ${
|
||
selectedShot === shot.id ? 'border-blue-500' : 'border-gray-600'
|
||
}`}
|
||
onClick={() => setSelectedShot(shot.id)}
|
||
>
|
||
<div className="w-32 h-20 relative">
|
||
<video
|
||
src={shot.shotVideo}
|
||
className="w-full h-full object-cover"
|
||
autoPlay={false}
|
||
muted={false}
|
||
loop={false}
|
||
/>
|
||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||
00:{shot.duration.toString().padStart(2, '0')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 标签页 */}
|
||
<Tabs defaultValue={activeTabReplaceMedia} className="w-full">
|
||
<TabsList>
|
||
{replaceMediaTabs.map((tab) => (
|
||
<TabsTrigger key={tab.value} value={tab.value} className="w-full">{tab.label}</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
<TabsContent value="uploaded">
|
||
<div className="flex flex-col gap-4">
|
||
{/* 上传媒体 */}
|
||
{/* 上传按钮;筛选下拉框 视频 图片;一行展示 两边对齐 */}
|
||
<div className="flex flex-row gap-4">
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
||
>
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
Uploaded media
|
||
</Button>
|
||
<Select defaultValue="all">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a type" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All</SelectItem>
|
||
<SelectItem value="video">Video</SelectItem>
|
||
<SelectItem value="image">Image</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 媒体库 */}
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{/* 媒体库 网格展示 */}
|
||
</div>
|
||
{/* 没有数据 提示 请上传媒体 */}
|
||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||
<p className="text-gray-300">No media uploaded</p>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="stock">
|
||
<div className="flex flex-col gap-4">
|
||
{/* 素材库 */}
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="generative">
|
||
<div className="flex flex-col gap-4">
|
||
{/* 生成媒体 */}
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
{/* Apply 按钮 Cancel 按钮 */}
|
||
<div className="flex justify-end gap-4 fixed bottom-0 left-0 right-0 z-10 p-5">
|
||
<Button variant="outline">Cancel</Button>
|
||
<Button>Apply</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
{/* 媒体属性 弹窗 高度全屏 */}
|
||
<Dialog open={isMediaPropertyOpen} onOpenChange={setIsMediaPropertyOpen}>
|
||
<DialogContent className="max-w-5xl w-full h-full overflow-hidden p-0">
|
||
<DialogHeader className="fixed top-0 left-5 right-10 z-10 h-10 flex justify-center">
|
||
<DialogTitle>Media properties</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex mt-5 hide-scrollbar p-5">
|
||
{/* 左侧内容区域 */}
|
||
<div className="flex-1 pr-4">
|
||
{/* 标签页 */}
|
||
<Tabs value={activeTabMediaProperty} onValueChange={setActiveTabMediaProperty} className="h-[calc(100vh-7rem)] overflow-hidden">
|
||
<TabsList className="grid w-full grid-cols-2">
|
||
{mediaPropertyTabs.map((tab) => (
|
||
<TabsTrigger key={tab.value} value={tab.value} className="w-full">{tab.label}</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
|
||
<TabsContent value="media" className="space-y-6 mt-6 h-[calc(100%-5rem)] overflow-y-auto hide-scrollbar">
|
||
{/* Duration */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Duration</Label>
|
||
<div className="text-sm text-gray-300">00m : 10s : 500ms / 00m : 17s : 320ms</div>
|
||
</div>
|
||
|
||
{/* Trim */}
|
||
<div className="space-y-4">
|
||
<Label className="text-sm font-medium">Trim</Label>
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox id="trim-auto" />
|
||
<Label htmlFor="trim-auto" className="text-sm">Trim automatically</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm">from</Label>
|
||
<Input
|
||
type="text"
|
||
placeholder="0.00s"
|
||
className="w-20 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm">to</Label>
|
||
<Input
|
||
type="text"
|
||
placeholder="s"
|
||
className="w-20 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center point */}
|
||
<div className="space-y-4">
|
||
<Label className="text-sm font-medium">Center point</Label>
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm w-4">X</Label>
|
||
<Input
|
||
type="text"
|
||
value="0.5"
|
||
className="w-16 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm w-4">Y</Label>
|
||
<Input
|
||
type="text"
|
||
value="0.5"
|
||
className="w-16 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zoom & Rotation */}
|
||
<div className="space-y-4">
|
||
<Label className="text-sm font-medium">Zoom & Rotation</Label>
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm w-4">
|
||
<Ruler className="h-4 w-4" />
|
||
</Label>
|
||
<Input
|
||
type="text"
|
||
value="0.5"
|
||
className="w-16 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Label className="text-sm w-4">
|
||
<RotateCcw className="h-4 w-4" />
|
||
</Label>
|
||
<Input
|
||
type="text"
|
||
value="0.5"
|
||
className="w-16 h-8 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Transition */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Transition</Label>
|
||
<Select defaultValue="auto">
|
||
<SelectTrigger className="w-32 h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="auto">Auto</SelectItem>
|
||
<SelectItem value="fade">Fade</SelectItem>
|
||
<SelectItem value="slide">Slide</SelectItem>
|
||
<SelectItem value="zoom">Zoom</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Script */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Script</Label>
|
||
<div className="text-sm text-gray-300">This part of the script is 21.00 seconds long.</div>
|
||
<div className="text-sm text-gray-400 mt-2">There are 2 media attached to this part of the script:</div>
|
||
|
||
{/* 章节列表 */}
|
||
<div>
|
||
{chapters.map((chapter, index) => (
|
||
<div key={chapter.id} className="flex items-center justify-between">
|
||
{/* Chapter: index + 1 文字竖着显示 */}
|
||
<div className="text-sm text-gray-300" style={{
|
||
transform: 'rotate(180deg)',
|
||
whiteSpace: 'nowrap',
|
||
height: 'fit-content',
|
||
writingMode: 'vertical-lr',
|
||
marginRight: '5px'
|
||
}}>
|
||
<div>Chapter {chapter.id}</div>
|
||
</div>
|
||
{/* flex: 1 */}
|
||
<div className="flex flex-1 space-x-2 overflow-x-auto pb-2">
|
||
{chapter.shots.map((shot, index) => (
|
||
<div
|
||
key={shot.id}
|
||
className={`relative flex-shrink-0 cursor-pointer rounded-lg overflow-hidden border-2 ${
|
||
selectedShot === shot.id ? 'border-blue-500' : 'border-gray-600'
|
||
}`}
|
||
onClick={() => setSelectedShot(shot.id)}
|
||
>
|
||
<div className="w-32 h-20 relative">
|
||
<video
|
||
src={shot.shotVideo}
|
||
className="w-full h-full object-cover"
|
||
autoPlay={false}
|
||
muted={false}
|
||
loop={false}
|
||
/>
|
||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||
00:{shot.duration.toString().padStart(2, '0')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="audio" className="space-y-6 mt-6">
|
||
{/* SFX name */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">SFX name</Label>
|
||
<div className="text-sm text-gray-300">Airplane Rocket Fire Close</div>
|
||
</div>
|
||
|
||
{/* SFX volume */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium flex items-center">
|
||
SFX volume
|
||
<span className="ml-auto text-gray-300">30%</span>
|
||
</Label>
|
||
<Slider
|
||
value={[30]}
|
||
max={100}
|
||
step={1}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Replace audio */}
|
||
<div className="space-y-4">
|
||
<Label className="text-sm font-medium">Replace audio</Label>
|
||
<div className="flex space-x-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-white"
|
||
>
|
||
Upload audio
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-white"
|
||
>
|
||
Stock SFX
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-white"
|
||
>
|
||
Generate SFX
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
{/* 右侧预览区域 */}
|
||
<div className="w-80 rounded-lg p-4">
|
||
{currentShot && (
|
||
<div className="space-y-4">
|
||
{/* 视频预览 */}
|
||
<div className="aspect-video bg-black rounded overflow-hidden border border-yellow-500">
|
||
<video
|
||
src={currentShot.shotVideo}
|
||
className="w-full h-full object-cover"
|
||
controls
|
||
muted
|
||
/>
|
||
</div>
|
||
|
||
<div className="text-sm text-gray-300">
|
||
Chapter 1 / media 1 / People gathered in a city square to watch a fireworks display
|
||
</div>
|
||
|
||
{/* 音频波形 */}
|
||
<div className="bg-gray-700 rounded p-2">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center space-x-2">
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||
<Play className="h-3 w-3" />
|
||
</Button>
|
||
<span className="text-xs text-gray-300">0:00</span>
|
||
</div>
|
||
<span className="text-xs text-gray-300">0:12</span>
|
||
</div>
|
||
{/* 简单的音频波形显示 */}
|
||
<div className="h-8 bg-gray-600 rounded flex items-end justify-center space-x-px overflow-hidden">
|
||
{Array.from({ length: 40 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="bg-gray-300 w-1"
|
||
style={{
|
||
height: `${Math.random() * 100}%`,
|
||
minHeight: '10%'
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div className="text-xs text-gray-400 mt-2">
|
||
Chapter 1 / Audio & SFX / Airplane Rocket Fire Close
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部按钮 */}
|
||
<div className="flex justify-end gap-4 pt-4 fixed bottom-0 left-0 right-10 z-10 p-5">
|
||
<Button variant="outline" className="text-white">
|
||
Reset
|
||
</Button>
|
||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||
Apply
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
{/* Timeline Header */}
|
||
<div className=" p-4">
|
||
{/* 章节列表 */}
|
||
<div>
|
||
{chapters.map((chapter, index) => (
|
||
<div key={chapter.id} className="flex items-center justify-between">
|
||
{/* Chapter: index + 1 文字竖着显示 */}
|
||
<div className="text-sm text-gray-300" style={{
|
||
transform: 'rotate(180deg)',
|
||
whiteSpace: 'nowrap',
|
||
height: 'fit-content',
|
||
writingMode: 'vertical-lr',
|
||
marginRight: '5px'
|
||
}}>
|
||
<div>Chapter {chapter.id}</div>
|
||
</div>
|
||
{/* flex: 1 */}
|
||
<div className="flex flex-1 space-x-2 overflow-x-auto pb-2">
|
||
{chapter.shots.map((shot, index) => (
|
||
<div
|
||
key={shot.id}
|
||
className={`relative flex-shrink-0 cursor-pointer rounded-lg overflow-hidden border-2 ${
|
||
selectedShot === shot.id ? 'border-blue-500' : 'border-gray-600'
|
||
}`}
|
||
onClick={() => setSelectedShot(shot.id)}
|
||
>
|
||
<div className="w-32 h-20 relative">
|
||
<video
|
||
src={shot.shotVideo}
|
||
className="w-full h-full object-cover"
|
||
autoPlay={false}
|
||
muted={false}
|
||
loop={false}
|
||
/>
|
||
<div className="absolute top-1 left-1">
|
||
{/* 图层icon 点击打开弹窗 视频列表 generatedVideos 选择替换 */}
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 bg-black/50 hover:bg-black/70" onClick={() => {
|
||
setIsCheckVideoOpen(true);
|
||
}}>
|
||
<Layers className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||
00:{shot.duration.toString().padStart(2, '0')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Chinese Text 不换行 溢出可左右滚动 隐藏滚动条 */}
|
||
<div className="text-sm text-gray-300 leading-relaxed overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||
但我决心要改变它。我的翅膀展开,在千星的光芒中翱翔,降临人生。和星光铸就,我是凤青楗,这就是我重生的故事。不......?
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content */}
|
||
<div className="flex">
|
||
{/* Left Panel */}
|
||
<div className="w-full p-6 space-y-6">
|
||
|
||
{/* Replace Media Section */}
|
||
{currentShot && (
|
||
<div className="space-y-4">
|
||
<h2 className="text-lg font-medium">Replace media {currentShot.mediaNumber} with:</h2>
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
||
onClick={() => handleOpenReplaceMedia('uploaded')}
|
||
>
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
Uploaded media
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
||
onClick={() => handleOpenReplaceMedia('stock')}
|
||
>
|
||
<Image className="mr-2 h-4 w-4" />
|
||
Stock media
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
||
onClick={() => handleOpenReplaceMedia('generative')}
|
||
>
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
Generative Media
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Media Info */}
|
||
{currentShot && (
|
||
<div className="flex">
|
||
<div className="space-y-4 w-2/3">
|
||
<h3 className="text-lg font-medium">Media info:</h3>
|
||
|
||
<div className="space-y-3">
|
||
{/* Chapter/Media Info */}
|
||
<div className="flex items-center space-x-3">
|
||
{/* 文档图标 */}
|
||
<File className="h-4 w-4 text-gray-400" />
|
||
<span className="text-sm text-gray-300">
|
||
Chapter 1 / media {currentShot.mediaNumber} / Generated media
|
||
</span>
|
||
<Popover>
|
||
<PopoverTrigger>
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0">
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" sideOffset={-40} className="w-100 ml-8 p-0">
|
||
<div className="flex flex-col">
|
||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Delete media</div>
|
||
<div className="flex flex-col gap-2">
|
||
<div className="p-2 cursor-pointer hover:bg-gray-700">Delete and add blank media</div>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Duration */}
|
||
<div className="flex items-center space-x-3">
|
||
{/* 标尺图标 */}
|
||
<Ruler className="h-4 w-4 text-gray-400" />
|
||
<span className="text-sm text-gray-300">
|
||
00m : 08s : 070ms / 00m : 08s : 070ms
|
||
</span>
|
||
<Popover>
|
||
<PopoverTrigger>
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0">
|
||
<Edit className="h-3 w-3" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" sideOffset={-40} className="w-100 ml-8 p-0">
|
||
<div className="flex flex-col">
|
||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Trim</div>
|
||
<div className="flex flex-col gap-2">
|
||
{/* checkbox */}
|
||
<div className="flex items-center space-x-2 p-2">
|
||
<Checkbox id="trim" />
|
||
<Label htmlFor="trim">Trim automatically</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2 p-2">
|
||
<Label htmlFor="trim">From</Label>
|
||
<Input type="text" placeholder="00:00" />
|
||
<Label htmlFor="trim">To</Label>
|
||
<Input type="text" placeholder="00:00" />
|
||
</div>
|
||
{/* 按钮 */}
|
||
<div className="flex justify-end p-2">
|
||
<Button variant="outline" className="mr-2">Cancel</Button>
|
||
<Button>Apply</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Transition */}
|
||
<div className="flex items-center space-x-3">
|
||
{/* 图标 */}
|
||
<UnfoldHorizontal className="h-4 w-4 text-gray-400" />
|
||
<span className="text-sm text-gray-300">
|
||
Transition: {currentShot.transition}
|
||
</span>
|
||
<Popover>
|
||
<PopoverTrigger>
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0">
|
||
<Edit className="h-3 w-3" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" sideOffset={-40} className="w-100 ml-8 p-0">
|
||
<div className="flex flex-col">
|
||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Transition</div>
|
||
<div className="flex flex-col gap-2">
|
||
{/* select */}
|
||
<div className="flex items-center space-x-2 p-2">
|
||
<Select>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a transition" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="fade">Fade</SelectItem>
|
||
<SelectItem value="slide">Slide</SelectItem>
|
||
<SelectItem value="zoom">Zoom</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 按钮 */}
|
||
<div className="flex justify-end p-2">
|
||
<Button variant="outline" className="mr-2">Cancel</Button>
|
||
<Button>Apply</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Audio Volume */}
|
||
<div className="flex items-center space-x-3">
|
||
<Volume2 className="h-4 w-4 text-gray-400" />
|
||
<span className="text-sm text-gray-300">
|
||
Audio volume: {currentShot.volume}% volume
|
||
</span>
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleOpenMediaProperty('audio')}>
|
||
<Edit className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Right Panel - Preview */}
|
||
<div className="w-1/3 p-6">
|
||
{currentShot && (
|
||
<div className="space-y-4">
|
||
<div className="aspect-video bg-black rounded-lg overflow-hidden relative">
|
||
<video
|
||
ref={videoRef}
|
||
src={currentShot.shotVideo}
|
||
className="w-full h-full object-cover"
|
||
controls
|
||
autoPlay={false}
|
||
muted={false}
|
||
loop={false}
|
||
/>
|
||
</div>
|
||
{/* 刷新图标 */}
|
||
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 border border-gray-600 rounded w-full">
|
||
<RefreshCcw className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Media Properties */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-medium underline cursor-pointer" onClick={() => handleOpenMediaProperty('media')}>Media properties</h3>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex justify-between p-6 ">
|
||
<Button variant="outline" onClick={onPrevious} className="bg-gray-700 border-gray-600 text-white hover:bg-gray-600">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
返回章节
|
||
</Button>
|
||
<Button onClick={onNext} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||
添加背景音乐
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |