forked from 77media/video-flow
514 lines
24 KiB
TypeScript
514 lines
24 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { X, Play, Pause, Volume2, VolumeX, Upload, Library, Wand2, ZoomIn, RotateCw, Info, ChevronDown } from 'lucide-react';
|
||
import { cn } from '@/public/lib/utils';
|
||
import { AudioVisualizer } from './audio-visualizer';
|
||
|
||
interface MediaPropertiesModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
taskSketch: any[];
|
||
currentSketchIndex: number;
|
||
onSketchSelect: (index: number) => void;
|
||
}
|
||
|
||
export function MediaPropertiesModal({
|
||
isOpen,
|
||
onClose,
|
||
taskSketch = [],
|
||
currentSketchIndex = 0,
|
||
onSketchSelect
|
||
}: MediaPropertiesModalProps) {
|
||
const [activeTab, setActiveTab] = useState<'media' | 'audio'>('media');
|
||
const [selectedSketchIndex, setSelectedSketchIndex] = useState(currentSketchIndex);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [trimAutomatically, setTrimAutomatically] = useState(false);
|
||
const [audioVolume, setAudioVolume] = useState(75);
|
||
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 当弹窗打开时,同步当前选中的分镜
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
setSelectedSketchIndex(currentSketchIndex);
|
||
}
|
||
}, [isOpen, currentSketchIndex]);
|
||
|
||
// 确保 taskSketch 是数组
|
||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||
|
||
// 模拟媒体属性数据
|
||
const currentSketch = sketches[selectedSketchIndex];
|
||
const mediaProperties = {
|
||
duration: '00m : 10s : 500ms / 00m : 17s : 320ms',
|
||
trim: { start: '0.0s', end: '10.5s' },
|
||
centerPoint: { x: 0.5, y: 0.5 },
|
||
zoom: 100,
|
||
rotation: 0,
|
||
transition: 'Auto',
|
||
script: 'This part of the script is 21.00 seconds long.'
|
||
};
|
||
|
||
const audioProperties = {
|
||
sfxName: 'Background Music',
|
||
sfxVolume: audioVolume
|
||
};
|
||
|
||
// 自动滚动到选中项
|
||
useEffect(() => {
|
||
if (thumbnailsRef.current && isOpen) {
|
||
const thumbnailContainer = thumbnailsRef.current;
|
||
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
|
||
const thumbnailGap = 16;
|
||
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * selectedSketchIndex;
|
||
|
||
thumbnailContainer.scrollTo({
|
||
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
}, [selectedSketchIndex, isOpen]);
|
||
|
||
// 视频播放控制
|
||
useEffect(() => {
|
||
if (videoPlayerRef.current) {
|
||
if (isPlaying) {
|
||
videoPlayerRef.current.play().catch(() => {
|
||
setIsPlaying(false);
|
||
});
|
||
} else {
|
||
videoPlayerRef.current.pause();
|
||
}
|
||
}
|
||
}, [isPlaying, selectedSketchIndex]);
|
||
|
||
// 更新进度条
|
||
const handleTimeUpdate = () => {
|
||
if (videoPlayerRef.current) {
|
||
const progress = (videoPlayerRef.current.currentTime / videoPlayerRef.current.duration) * 100;
|
||
setProgress(progress);
|
||
}
|
||
};
|
||
|
||
const handleSketchSelect = (index: number) => {
|
||
setSelectedSketchIndex(index);
|
||
setProgress(0);
|
||
};
|
||
|
||
if (sketches.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<>
|
||
{/* 背景遮罩 */}
|
||
<motion.div
|
||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
/>
|
||
|
||
{/* 弹窗内容 */}
|
||
<div className="fixed inset-x-0 bottom-0 z-50 flex justify-center">
|
||
<motion.div
|
||
className="w-[88%] min-w-[888px] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
|
||
initial={{ y: '100%' }}
|
||
animate={{ y: 0 }}
|
||
exit={{ y: '100%' }}
|
||
transition={{
|
||
type: 'spring',
|
||
damping: 25,
|
||
stiffness: 200,
|
||
}}
|
||
>
|
||
{/* 标题栏 */}
|
||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
||
onClick={onClose}
|
||
>
|
||
<ChevronDown className="w-5 h-5" />
|
||
</button>
|
||
<h2 className="text-lg font-medium">More Properties</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[80vh] flex flex-col">
|
||
{/* 上部:分镜视频列表 */}
|
||
<div className="p-4 border-b border-white/10">
|
||
<div
|
||
ref={thumbnailsRef}
|
||
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
||
>
|
||
{sketches.map((sketch, index) => (
|
||
<motion.div
|
||
key={sketch.id || index}
|
||
className={cn(
|
||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer',
|
||
selectedSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||
)}
|
||
onClick={() => handleSketchSelect(index)}
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<video
|
||
src={sketch.url}
|
||
className="w-full h-full object-cover"
|
||
muted
|
||
loop
|
||
playsInline
|
||
onMouseEnter={(e) => e.currentTarget.play()}
|
||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 下部:主要内容区域 */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
{/* 左侧 2/3:编辑选项 */}
|
||
<div className="flex-[2] p-4 border-r border-white/10 overflow-y-auto">
|
||
{/* Media/Audio & SFX 切换按钮 */}
|
||
<div className="flex gap-2 mb-6">
|
||
<motion.button
|
||
className={cn(
|
||
'px-4 py-2 rounded-lg transition-colors',
|
||
activeTab === 'media'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||
)}
|
||
onClick={() => setActiveTab('media')}
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
Media
|
||
</motion.button>
|
||
<motion.button
|
||
className={cn(
|
||
'px-4 py-2 rounded-lg transition-colors',
|
||
activeTab === 'audio'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||
)}
|
||
onClick={() => setActiveTab('audio')}
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
Audio & SFX
|
||
</motion.button>
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div className="space-y-4">
|
||
{activeTab === 'media' ? (
|
||
<>
|
||
{/* Duration - 只读 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<label className="text-sm font-medium text-white/80">Duration</label>
|
||
<span className="text-sm text-white/70">{mediaProperties.duration}</span>
|
||
</div>
|
||
|
||
{/* Trim - 可编辑 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<label className="text-sm font-medium text-white/80">Trim</label>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-white/60">from</span>
|
||
<input
|
||
type="text"
|
||
value={mediaProperties.trim.start}
|
||
placeholder="0.0s"
|
||
className="w-16 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
<span className="text-sm text-white/60">to</span>
|
||
<input
|
||
type="text"
|
||
value={mediaProperties.trim.end}
|
||
placeholder="10.5s"
|
||
className="w-16 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center point - 可编辑 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<label className="text-sm font-medium text-white/80">Center point</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="number"
|
||
value={mediaProperties.centerPoint.x}
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
<span className="text-white/40">X</span>
|
||
<input
|
||
type="number"
|
||
value={mediaProperties.centerPoint.y}
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
<span className="text-white/40">Y</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zoom & Rotation - 可编辑 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<label className="text-sm font-medium text-white/80">Zoom & Rotation</label>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1">
|
||
<ZoomIn className="w-4 h-4 text-white/60" />
|
||
<input
|
||
type="number"
|
||
value={mediaProperties.zoom}
|
||
min="10"
|
||
max="500"
|
||
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
<span className="text-xs text-white/60">%</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<RotateCw className="w-4 h-4 text-white/60" />
|
||
<input
|
||
type="number"
|
||
value={mediaProperties.rotation}
|
||
min="-360"
|
||
max="360"
|
||
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
|
||
/>
|
||
<span className="text-xs text-white/60">°</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Transition - 可编辑 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<div className="flex items-center gap-1">
|
||
<label className="text-sm font-medium text-white/80">Transition</label>
|
||
<Info className="w-3 h-3 text-white/40" />
|
||
</div>
|
||
<select
|
||
value={mediaProperties.transition}
|
||
className="px-3 py-1 bg-white/10 rounded text-sm text-white focus:outline-none focus:border-blue-500 appearance-none cursor-pointer"
|
||
style={{
|
||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||
backgroundPosition: 'right 0.5rem center',
|
||
backgroundRepeat: 'no-repeat',
|
||
backgroundSize: '1.5em 1.5em',
|
||
paddingRight: '2.5rem'
|
||
}}
|
||
>
|
||
<option value="Auto">Auto</option>
|
||
<option value="Fade">Fade</option>
|
||
<option value="Slide">Slide</option>
|
||
<option value="Zoom">Zoom</option>
|
||
<option value="Rotate">Rotate</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Script - 只读 */}
|
||
<div className="py-2">
|
||
<label className="block text-sm font-medium text-white/80 mb-2">Script</label>
|
||
<p className="text-sm text-white/70 leading-relaxed">
|
||
{mediaProperties.script}
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* SFX name - 只读 */}
|
||
<div className="flex items-center justify-between py-2">
|
||
<label className="text-sm font-medium text-white/80">SFX name</label>
|
||
<span className="text-sm text-white/70">{audioProperties.sfxName}</span>
|
||
</div>
|
||
|
||
{/* SFX volume - 可编辑 */}
|
||
<div className="py-2">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-sm font-medium text-white/80">SFX volume</label>
|
||
<span className="text-sm text-white/70">{audioProperties.sfxVolume}%</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
value={audioProperties.sfxVolume}
|
||
onChange={(e) => setAudioVolume(parseInt(e.target.value))}
|
||
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
|
||
style={{
|
||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${audioProperties.sfxVolume}%, rgba(255,255,255,0.2) ${audioProperties.sfxVolume}%, rgba(255,255,255,0.2) 100%)`
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Replace audio */}
|
||
<div className="py-2">
|
||
<label className="block text-sm font-medium text-white/80 mb-3">Replace audio</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
<motion.button
|
||
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
Replace audio
|
||
</motion.button>
|
||
<motion.button
|
||
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
<Library className="w-4 h-4" />
|
||
Stock SFX
|
||
</motion.button>
|
||
<motion.button
|
||
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
<Wand2 className="w-4 h-4" />
|
||
Generate SFX
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧 1/3:预览区域 */}
|
||
<div className="flex-1 p-4">
|
||
<div className="space-y-4">
|
||
{/* 视频预览 */}
|
||
<div className={cn(
|
||
'aspect-video rounded-lg overflow-hidden relative group border-2',
|
||
activeTab === 'media' ? 'border-blue-500' : 'border-white/20'
|
||
)}>
|
||
<video
|
||
ref={videoPlayerRef}
|
||
src={sketches[selectedSketchIndex]?.url}
|
||
className="w-full h-full object-cover"
|
||
loop
|
||
playsInline
|
||
muted={isMuted}
|
||
onTimeUpdate={handleTimeUpdate}
|
||
/>
|
||
|
||
{/* 视频控制层 */}
|
||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||
{/* 进度条 */}
|
||
<div className="w-full h-1 bg-white/20 rounded-full mb-2 cursor-pointer"
|
||
onClick={(e) => {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const percentage = (x / rect.width) * 100;
|
||
if (videoPlayerRef.current) {
|
||
videoPlayerRef.current.currentTime = (percentage / 100) * videoPlayerRef.current.duration;
|
||
}
|
||
}}
|
||
>
|
||
<motion.div
|
||
className="h-full bg-blue-500 rounded-full"
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 控制按钮 */}
|
||
<div className="flex items-center gap-2">
|
||
<motion.button
|
||
className="p-1 rounded-full hover:bg-white/10"
|
||
onClick={() => setIsPlaying(!isPlaying)}
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
{isPlaying ? (
|
||
<Pause className="w-4 h-4" />
|
||
) : (
|
||
<Play className="w-4 h-4" />
|
||
)}
|
||
</motion.button>
|
||
|
||
<motion.button
|
||
className="p-1 rounded-full hover:bg-white/10"
|
||
onClick={() => setIsMuted(!isMuted)}
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
{isMuted ? (
|
||
<VolumeX className="w-4 h-4" />
|
||
) : (
|
||
<Volume2 className="w-4 h-4" />
|
||
)}
|
||
</motion.button>
|
||
|
||
<span className="text-xs ml-auto">00:21</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频预览 */}
|
||
<AudioVisualizer
|
||
audioUrl="/audio/demo.mp3" // 可以根据选中的分镜动态改变
|
||
title={audioProperties.sfxName}
|
||
volume={audioVolume}
|
||
isActive={activeTab === 'audio'}
|
||
onVolumeChange={setAudioVolume}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部操作栏 */}
|
||
<div className="p-4 border-t border-white/10 bg-black/20">
|
||
<div className="flex justify-end gap-3">
|
||
<motion.button
|
||
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => {
|
||
// TODO: 实现重置逻辑
|
||
console.log('Reset clicked');
|
||
}}
|
||
>
|
||
Reset
|
||
</motion.button>
|
||
<motion.button
|
||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => {
|
||
// TODO: 实现应用逻辑
|
||
console.log('Apply clicked');
|
||
onSketchSelect(selectedSketchIndex);
|
||
onClose();
|
||
}}
|
||
>
|
||
Apply
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|