video-flow-b/components/ui/media-properties-modal.tsx
2025-07-29 10:57:25 +08:00

514 lines
24 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, 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>
);
}