forked from 77media/video-flow
836 lines
32 KiB
TypeScript
836 lines
32 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;
|
||
}
|
||
|
||
interface Shot {
|
||
id: string;
|
||
type: string;
|
||
duration: number;
|
||
shotVideo: string;
|
||
generatedVideos: string[];
|
||
description: string;
|
||
transition: string;
|
||
volume: number;
|
||
mediaNumber: number;
|
||
}
|
||
|
||
interface Chapter {
|
||
id: number;
|
||
title: string;
|
||
shots: Shot[];
|
||
}
|
||
|
||
// 时间轴组件
|
||
const TimelineView = ({
|
||
chapters,
|
||
selectedShot,
|
||
onShotSelect,
|
||
onVideoCheck
|
||
}: {
|
||
chapters: Chapter[];
|
||
selectedShot: string;
|
||
onShotSelect: (shotId: string) => void;
|
||
onVideoCheck?: () => void;
|
||
}) => (
|
||
<div>
|
||
{chapters.map((chapter) => (
|
||
<div key={chapter.id} className="flex items-center justify-between">
|
||
<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>
|
||
<div className="flex flex-1 space-x-2 overflow-x-auto pb-2">
|
||
{chapter.shots.map((shot) => (
|
||
<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={() => onShotSelect(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}
|
||
/>
|
||
{onVideoCheck && (
|
||
<div className="absolute top-1 left-1">
|
||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 bg-black/50 hover:bg-black/70" onClick={onVideoCheck}>
|
||
<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>
|
||
);
|
||
|
||
// 媒体信息项组件
|
||
const MediaInfoItem = ({
|
||
icon,
|
||
text,
|
||
popoverContent
|
||
}: {
|
||
icon: React.ReactNode;
|
||
text: string;
|
||
popoverContent?: React.ReactNode;
|
||
}) => (
|
||
<div className="flex items-center space-x-3">
|
||
{icon}
|
||
<span className="text-sm text-gray-300">{text}</span>
|
||
{popoverContent && (
|
||
<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">
|
||
{popoverContent}
|
||
</PopoverContent>
|
||
</Popover>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// 查看视频弹窗
|
||
const CheckVideoDialog = ({
|
||
isOpen,
|
||
onClose,
|
||
currentShot
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: (open: boolean) => void;
|
||
currentShot?: Shot;
|
||
}) => (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<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>
|
||
<div className="flex justify-end gap-4">
|
||
<Button variant="outline">Cancel</Button>
|
||
<Button>Apply</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
|
||
// 替换媒体弹窗
|
||
const ReplaceMediaDialog = ({
|
||
isOpen,
|
||
onClose,
|
||
chapters,
|
||
selectedShot,
|
||
onShotSelect,
|
||
activeTab,
|
||
setActiveTab
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: (open: boolean) => void;
|
||
chapters: Chapter[];
|
||
selectedShot: string;
|
||
onShotSelect: (shotId: string) => void;
|
||
activeTab: string;
|
||
setActiveTab: (tab: string) => void;
|
||
}) => {
|
||
const replaceMediaTabs = [
|
||
{ value: 'uploaded', label: 'Uploaded media' },
|
||
{ value: 'stock', label: 'Stock media' },
|
||
{ value: 'generative', label: 'Generative media' },
|
||
];
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<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">
|
||
<TimelineView
|
||
chapters={chapters}
|
||
selectedShot={selectedShot}
|
||
onShotSelect={onShotSelect}
|
||
/>
|
||
|
||
<Tabs defaultValue={activeTab} 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>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
// 媒体属性弹窗
|
||
const MediaPropertyDialog = ({
|
||
isOpen,
|
||
onClose,
|
||
chapters,
|
||
selectedShot,
|
||
onShotSelect,
|
||
currentShot,
|
||
activeTab,
|
||
setActiveTab
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: (open: boolean) => void;
|
||
chapters: Chapter[];
|
||
selectedShot: string;
|
||
onShotSelect: (shotId: string) => void;
|
||
currentShot?: Shot;
|
||
activeTab: string;
|
||
setActiveTab: (tab: string) => void;
|
||
}) => {
|
||
const mediaPropertyTabs = [
|
||
{ value: 'media', label: 'Media' },
|
||
{ value: 'audio', label: 'Audio & SFX' },
|
||
];
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<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={activeTab} onValueChange={setActiveTab} 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">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<TimelineView
|
||
chapters={chapters}
|
||
selectedShot={selectedShot}
|
||
onShotSelect={onShotSelect}
|
||
/>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="audio" className="space-y-6 mt-6">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
);
|
||
};
|
||
|
||
const mockChapters: Chapter[] = [
|
||
{
|
||
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,
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
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 [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 currentChapter = chapters.find(ch => ch.id === selectedChapter);
|
||
const currentShot = currentChapter?.shots.find(shot => shot.id === selectedShot);
|
||
|
||
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">
|
||
{/* 弹窗组件 */}
|
||
<CheckVideoDialog
|
||
isOpen={isCheckVideoOpen}
|
||
onClose={setIsCheckVideoOpen}
|
||
currentShot={currentShot}
|
||
/>
|
||
|
||
<ReplaceMediaDialog
|
||
isOpen={isReplaceMediaOpen}
|
||
onClose={setIsReplaceMediaOpen}
|
||
chapters={chapters}
|
||
selectedShot={selectedShot}
|
||
onShotSelect={setSelectedShot}
|
||
activeTab={activeTabReplaceMedia}
|
||
setActiveTab={setActiveTabReplaceMedia}
|
||
/>
|
||
|
||
<MediaPropertyDialog
|
||
isOpen={isMediaPropertyOpen}
|
||
onClose={setIsMediaPropertyOpen}
|
||
chapters={chapters}
|
||
selectedShot={selectedShot}
|
||
onShotSelect={setSelectedShot}
|
||
currentShot={currentShot}
|
||
activeTab={activeTabMediaProperty}
|
||
setActiveTab={setActiveTabMediaProperty}
|
||
/>
|
||
|
||
{/* Timeline Header */}
|
||
<div className="p-4">
|
||
<TimelineView
|
||
chapters={chapters}
|
||
selectedShot={selectedShot}
|
||
onShotSelect={setSelectedShot}
|
||
onVideoCheck={() => setIsCheckVideoOpen(true)}
|
||
/>
|
||
|
||
<div className="text-sm text-gray-300 leading-relaxed overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||
但我决心要改变它。我的翅膀展开,在千星的光芒中翱翔,降临人生。和星光铸就,我是凤青楗,这就是我重生的故事。不......?
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content */}
|
||
<div className="flex">
|
||
<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">
|
||
<MediaInfoItem
|
||
icon={<File className="h-4 w-4 text-gray-400" />}
|
||
text={`Chapter 1 / media ${currentShot.mediaNumber} / Generated media`}
|
||
popoverContent={
|
||
<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>
|
||
}
|
||
/>
|
||
|
||
<MediaInfoItem
|
||
icon={<Ruler className="h-4 w-4 text-gray-400" />}
|
||
text="00m : 08s : 070ms / 00m : 08s : 070ms"
|
||
popoverContent={
|
||
<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">
|
||
<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>
|
||
}
|
||
/>
|
||
|
||
<MediaInfoItem
|
||
icon={<UnfoldHorizontal className="h-4 w-4 text-gray-400" />}
|
||
text={`Transition: ${currentShot.transition}`}
|
||
popoverContent={
|
||
<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">
|
||
<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>
|
||
}
|
||
/>
|
||
|
||
<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>
|
||
|
||
{/* 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>
|
||
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex justify-between">
|
||
<Button variant="outline" onClick={onPrevious}>
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
Back to Chapters
|
||
</Button>
|
||
<Button onClick={onNext}>
|
||
Add Background Music
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |