Files

1104 lines
54 KiB
TypeScript
Raw Permalink 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.
import { useState } from 'react'
import { shallow } from 'zustand/vanilla/shallow'
import {
Trash2, FileVideo, FileImage, FileAudio, Layers, Type,
FlipHorizontal2, FlipVertical2, ChevronDown, ChevronRight,
Palette, Eye, Sun, Contrast, Droplets, Thermometer,
SunDim, Moon, RotateCcw, Film, // EFFECTS HIDDEN: removed EyeOff, Sparkles, Plus, X
AlignLeft, AlignCenter, AlignRight,
} from 'lucide-react'
import type { Asset, TimelineClip, LetterboxSettings, TextOverlayStyle, TransitionType } from '../../types/project-model' // EFFECTS HIDDEN: removed EffectMask
import { DEFAULT_COLOR_CORRECTION, DEFAULT_LETTERBOX } from '../../types/project-model' // EFFECTS HIDDEN: removed EFFECT_DEFINITIONS, DEFAULT_EFFECT_MASK
import { TEXT_PRESETS } from '../../types/project'
import { namedResolutionTier } from '../../lib/video-resolution'
import { formatTime } from './video-editor-utils'
import { Tooltip } from '../../components/ui/tooltip'
import {
selectAssets,
selectSelectedClipAudioControls,
selectSelectedClipForProperties,
selectTracks,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
interface ClipPropertiesPanelProps {
onCreateVideoFromImage: (clip: TimelineClip) => void
}
export function ClipPropertiesPanel(props: ClipPropertiesPanelProps) {
const {
onCreateVideoFromImage,
} = props
const {
deleteClipDisplayedTake,
setClipAudioLevel,
setClipAudioMuted,
updateClip,
} = useEditorActions()
const assets = useEditorStore(selectAssets)
const tracks = useEditorStore(selectTracks)
const selectedClip = useEditorStore(selectSelectedClipForProperties)
const clipAudioControls = useEditorStore(selectSelectedClipAudioControls, shallow)
if (!selectedClip) return null
const effectiveMuted = clipAudioControls?.muted ?? (selectedClip.muted || false)
const effectiveVolume = clipAudioControls?.volume ?? (selectedClip.volume ?? 1)
const getLiveAsset = (clip: TimelineClip): Asset | null | undefined => {
if (!clip.assetId) return clip.asset
return assets.find(asset => asset.id === clip.assetId) || clip.asset
}
const getMaxClipDuration = (clip: TimelineClip): number => {
const liveAsset = getLiveAsset(clip)
if (clip.type !== 'video' || !liveAsset?.duration) return Infinity
const mediaDuration = liveAsset.duration
const usableMedia = mediaDuration - clip.trimStart - clip.trimEnd
return Math.max(0.5, usableMedia / clip.speed)
}
const handleDeleteDisplayedTake = () => {
deleteClipDisplayedTake(selectedClip.id)
}
const [propertiesTab, setPropertiesTab] = useState<'properties' | 'metadata'>('properties')
const [showFlip, setShowFlip] = useState(false)
const [showTransitions, setShowTransitions] = useState(false)
const [showColorCorrection, setShowColorCorrection] = useState(false)
const getClipDimensions = (clip: TimelineClip): { width: number; height: number } | null => {
if (clip.type === 'audio') return null
const liveAsset = getLiveAsset(clip)
if (!liveAsset) return null
const takeIndex = clip.takeIndex ?? liveAsset.activeTakeIndex
if (liveAsset.takes && liveAsset.takes.length > 0 && takeIndex !== undefined) {
const idx = Math.max(0, Math.min(takeIndex, liveAsset.takes.length - 1))
const take = liveAsset.takes[idx]
if (take.width && take.height) {
return { width: take.width, height: take.height }
}
}
if (liveAsset.width && liveAsset.height) {
return { width: liveAsset.width, height: liveAsset.height }
}
return null
}
const isTextClip = selectedClip.type === 'text'
const hasPlaybackControls = selectedClip.type === 'video' || selectedClip.type === 'audio'
const hasAudioControls = selectedClip.type === 'video' || selectedClip.type === 'audio'
const hasVisualTransformControls = selectedClip.type === 'video' || selectedClip.type === 'image'
const hasTransitionControls = selectedClip.type === 'video' || selectedClip.type === 'image'
const hasColorCorrectionControls = selectedClip.type === 'video' || selectedClip.type === 'image'
return (
<div className="h-full w-full flex-shrink-0 border-l border-zinc-800 bg-zinc-900 p-4 overflow-auto">
{/* Tab header */}
<div className="flex items-center gap-0 mb-4 border-b border-zinc-700">
<button
className={`px-3 py-1.5 text-xs font-semibold transition-colors border-b-2 ${
propertiesTab === 'properties'
? 'text-white border-blue-500'
: 'text-zinc-500 border-transparent hover:text-zinc-300'
}`}
onClick={() => setPropertiesTab('properties')}
>
Properties
</button>
<button
className={`px-3 py-1.5 text-xs font-semibold transition-colors border-b-2 ${
propertiesTab === 'metadata'
? 'text-white border-blue-500'
: 'text-zinc-500 border-transparent hover:text-zinc-300'
}`}
onClick={() => setPropertiesTab('metadata')}
>
Metadata
</button>
</div>
{/* Metadata Tab */}
{propertiesTab === 'metadata' && (() => {
const liveAsset = getLiveAsset(selectedClip)
const dims = getClipDimensions(selectedClip)
const genParams = liveAsset?.generationParams
// Current take info
const totalTakes = liveAsset?.takes?.length || 1
const currentTakeIdx = selectedClip.takeIndex ?? (liveAsset?.activeTakeIndex ?? (totalTakes - 1))
const displayTakeNum = Math.min(currentTakeIdx, totalTakes - 1) + 1
// Get the file path for the current take
let filePath = liveAsset?.path || ''
if (liveAsset?.takes && liveAsset.takes.length > 0 && selectedClip.takeIndex !== undefined) {
const idx = Math.max(0, Math.min(selectedClip.takeIndex, liveAsset.takes.length - 1))
filePath = liveAsset.takes[idx].path
}
// Determine if this is an upscaled take (take index > 0 and resolution is higher than original)
const originalRes = liveAsset?.generationParams?.resolution
const qualityTier = dims ? namedResolutionTier(Math.min(dims.width, dims.height)) : 0
const isUpscaled = dims && originalRes ? qualityTier > parseInt(originalRes, 10) : false
return (
<div className="space-y-3">
{/* Currently Displayed */}
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Currently Displayed</h4>
<div className="bg-zinc-800/60 rounded-lg p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Take</span>
<div className="flex items-center gap-2">
<span className="text-xs text-white font-medium">{displayTakeNum} / {totalTakes}</span>
{totalTakes > 1 && (
<Tooltip content="Delete this take" side="left">
<button
onClick={() => {
if (confirm(`Delete take ${displayTakeNum}?`)) {
handleDeleteDisplayedTake()
}
}}
className="p-0.5 rounded hover:bg-red-900/50 text-zinc-500 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3 w-3" />
</button>
</Tooltip>
)}
</div>
</div>
{dims ? (
<>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Quality</span>
<span className="text-xs text-white">
{qualityTier >= 2160 ? 'Ultra HD' : qualityTier >= 1080 ? 'Full HD' : qualityTier >= 720 ? 'HD' : 'SD'}
{isUpscaled && <span className="ml-1.5 text-green-400">(Upscaled)</span>}
</span>
</div>
{dims.width > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Dimensions</span>
<span className="text-xs text-white font-mono">{dims.width} × {dims.height}</span>
</div>
)}
</>
) : (
<div className="text-xs text-zinc-500 italic">Dimension metadata unavailable.</div>
)}
{originalRes && originalRes !== 'imported' && (
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Original Gen</span>
<span className="text-xs text-zinc-500">{originalRes}</span>
</div>
)}
</div>
</div>
{/* Clip Info */}
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Clip Info</h4>
<div className="bg-zinc-800/60 rounded-lg p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Type</span>
<div className="flex items-center gap-1">
{selectedClip.type === 'video' && <FileVideo className="h-3 w-3 text-zinc-400" />}
{selectedClip.type === 'image' && <FileImage className="h-3 w-3 text-zinc-400" />}
{selectedClip.type === 'audio' && <FileAudio className="h-3 w-3 text-zinc-400" />}
{selectedClip.type === 'text' && <Type className="h-3 w-3 text-zinc-400" />}
<span className="text-xs text-white capitalize">{selectedClip.type}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Duration</span>
<span className="text-xs text-white">{selectedClip.duration.toFixed(2)}s</span>
</div>
{liveAsset?.duration && (
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Source Duration</span>
<span className="text-xs text-white">{liveAsset.duration.toFixed(2)}s</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Speed</span>
<span className="text-xs text-white">{selectedClip.speed}x</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Track</span>
<span className="text-xs text-white">{tracks[selectedClip.trackIndex]?.name || `Track ${selectedClip.trackIndex + 1}`}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Start</span>
<span className="text-xs text-white">{formatTime(selectedClip.startTime)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">End</span>
<span className="text-xs text-white">{formatTime(selectedClip.startTime + selectedClip.duration)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Trim In</span>
<span className="text-xs text-white">{selectedClip.trimStart.toFixed(2)}s</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Trim Out</span>
<span className="text-xs text-white">{selectedClip.trimEnd.toFixed(2)}s</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Opacity</span>
<span className="text-xs text-white">{selectedClip.opacity}%</span>
</div>
</div>
</div>
{/* Takes */}
{liveAsset?.takes && liveAsset.takes.length > 1 && (
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Takes</h4>
<div className="bg-zinc-800/60 rounded-lg p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Total Takes</span>
<span className="text-xs text-white">{liveAsset.takes.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Active Take</span>
<span className="text-xs text-white">
#{(selectedClip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)) + 1}
</span>
</div>
</div>
</div>
)}
{/* Generation Parameters */}
{genParams && (
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Generation</h4>
<div className="bg-zinc-800/60 rounded-lg p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Mode</span>
<span className="text-xs text-white">{genParams.mode.replace(/-/g, ' ')}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Model</span>
<span className="text-xs text-white">{genParams.model}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Gen Resolution</span>
<span className="text-xs text-white">{genParams.resolution}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">FPS</span>
<span className="text-xs text-white">{genParams.fps}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Duration</span>
<span className="text-xs text-white">{genParams.duration}s</span>
</div>
{genParams.cameraMotion && genParams.cameraMotion !== 'none' && (
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Camera</span>
<span className="text-xs text-white">{genParams.cameraMotion}</span>
</div>
)}
{genParams.prompt && (
<div className="mt-2">
<span className="text-xs text-zinc-400 block mb-1">Prompt</span>
<p className="text-xs text-zinc-300 bg-zinc-900/50 rounded p-2 break-words leading-relaxed">{genParams.prompt}</p>
</div>
)}
</div>
</div>
)}
{/* File Path */}
{filePath && (
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">File</h4>
<div className="bg-zinc-800/60 rounded-lg p-3">
<p className="text-[10px] text-zinc-400 break-all font-mono leading-relaxed">{filePath}</p>
</div>
</div>
)}
{/* Asset Created At */}
{liveAsset?.createdAt && (
<div className="space-y-2">
<h4 className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Created</h4>
<div className="bg-zinc-800/60 rounded-lg p-3">
<span className="text-xs text-zinc-300">{new Date(liveAsset.createdAt).toLocaleString()}</span>
</div>
</div>
)}
</div>
)
})()}
{/* Properties Tab */}
{propertiesTab === 'properties' && <div className="space-y-4">
{/* Adjustment Layer properties */}
{selectedClip.type === 'adjustment' && (() => {
const lb = { ...DEFAULT_LETTERBOX, ...selectedClip.letterbox }
const updateLetterbox = (patch: Partial<LetterboxSettings>) => {
updateClip(selectedClip.id, { letterbox: { ...lb, ...patch } })
}
return (
<div className="bg-blue-950/30 border border-blue-700/30 rounded-lg p-3 space-y-3">
<div className="flex items-center gap-2 mb-1">
<Layers className="h-4 w-4 text-blue-400" />
<h4 className="text-xs font-semibold text-blue-300">Adjustment Layer</h4>
</div>
{/* Letterbox toggle */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Letterbox</span>
<button
onClick={() => updateLetterbox({ enabled: !lb.enabled })}
className={`px-2.5 py-0.5 rounded text-[10px] border transition-colors ${
lb.enabled
? 'bg-blue-600/30 text-blue-300 border-blue-500/40'
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
}`}
>
{lb.enabled ? 'On' : 'Off'}
</button>
</div>
{lb.enabled && (
<>
{/* Aspect ratio */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Aspect Ratio</span>
<select
value={lb.aspectRatio}
onChange={e => updateLetterbox({ aspectRatio: e.target.value as LetterboxSettings['aspectRatio'] })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-blue-500/50"
>
<option value="2.39:1">2.39:1 (Anamorphic)</option>
<option value="2.35:1">2.35:1 (Cinemascope)</option>
<option value="2.76:1">2.76:1 (Ultra Panavision)</option>
<option value="1.85:1">1.85:1 (Flat Widescreen)</option>
<option value="4:3">4:3 (Classic TV)</option>
<option value="custom">Custom</option>
</select>
</div>
{/* Custom ratio input */}
{lb.aspectRatio === 'custom' && (
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Custom Ratio</span>
<input
type="number"
step={0.01}
min={1}
max={4}
value={lb.customRatio || 2.35}
onChange={e => updateLetterbox({ customRatio: parseFloat(e.target.value) || 2.35 })}
onKeyDown={e => e.stopPropagation()}
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white text-center focus:outline-none focus:border-blue-500/50"
/>
</div>
)}
{/* Bar color */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Bar Color</span>
<input
type="color"
value={lb.color}
onChange={e => updateLetterbox({ color: e.target.value })}
className="w-7 h-6 rounded cursor-pointer border border-zinc-700"
/>
</div>
{/* Bar opacity */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Bar Opacity</span>
<div className="flex items-center gap-2">
<input
type="range" min={0} max={100} value={lb.opacity}
onChange={e => updateLetterbox({ opacity: parseInt(e.target.value) })}
className="w-20 accent-blue-500"
/>
<span className="text-[10px] text-zinc-300 w-8 text-right tabular-nums">{lb.opacity}%</span>
</div>
</div>
</>
)}
{/* Color correction note */}
<p className="text-[9px] text-zinc-600 pt-1 border-t border-zinc-800">
Color correction on this layer affects all tracks below.
</p>
</div>
)
})()}
{/* Text overlay properties */}
{selectedClip.type === 'text' && selectedClip.textStyle && (() => {
const ts = selectedClip.textStyle
const updateText = (patch: Partial<TextOverlayStyle>) => {
updateClip(selectedClip.id, { textStyle: { ...ts, ...patch } })
}
return (
<div className="bg-cyan-950/30 border border-cyan-700/30 rounded-lg p-3 space-y-3">
<div className="flex items-center gap-2 mb-1">
<Type className="h-4 w-4 text-cyan-400" />
<h4 className="text-xs font-semibold text-cyan-300">Text Overlay</h4>
</div>
{/* Text content */}
<div className="space-y-1">
<span className="text-[10px] text-zinc-400">Content</span>
<textarea
value={ts.text}
onChange={e => updateText({ text: e.target.value })}
rows={3}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-xs text-white resize-none focus:outline-none focus:border-cyan-500/50"
placeholder="Enter text..."
/>
</div>
{/* Font family */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Font</span>
<select
value={ts.fontFamily.split(',')[0].trim()}
onChange={e => updateText({ fontFamily: `${e.target.value}, sans-serif` })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-cyan-500/50 max-w-[120px]"
>
<option value="Inter">Inter</option>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Verdana">Verdana</option>
<option value="Impact">Impact</option>
<option value="Comic Sans MS">Comic Sans MS</option>
</select>
</div>
{/* Font size */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Size</span>
<div className="flex items-center gap-2">
<input type="range" min={12} max={200} value={ts.fontSize} onChange={e => updateText({ fontSize: parseInt(e.target.value) })} className="w-20 accent-cyan-500" />
<span className="text-[10px] text-zinc-300 w-8 text-right tabular-nums">{ts.fontSize}</span>
</div>
</div>
{/* Font weight & style */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Weight</span>
<select
value={ts.fontWeight}
onChange={e => updateText({ fontWeight: e.target.value as TextOverlayStyle['fontWeight'] })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-cyan-500/50"
>
<option value="100">Thin</option>
<option value="300">Light</option>
<option value="normal">Normal</option>
<option value="500">Medium</option>
<option value="600">Semibold</option>
<option value="bold">Bold</option>
<option value="800">Extra Bold</option>
<option value="900">Black</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateText({ fontStyle: ts.fontStyle === 'italic' ? 'normal' : 'italic' })}
className={`px-2 py-1 rounded text-[10px] border ${ts.fontStyle === 'italic' ? 'bg-cyan-600/30 text-cyan-300 border-cyan-500/40' : 'bg-zinc-800 text-zinc-500 border-zinc-700'}`}
>
<em>Italic</em>
</button>
</div>
{/* Text color */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Color</span>
<input type="color" value={ts.color} onChange={e => updateText({ color: e.target.value })} className="w-7 h-6 rounded cursor-pointer border border-zinc-700" />
</div>
{/* Background color */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Background</span>
<div className="flex items-center gap-1.5">
<input type="color" value={ts.backgroundColor === 'transparent' ? '#000000' : ts.backgroundColor.slice(0, 7)} onChange={e => updateText({ backgroundColor: e.target.value + 'cc' })} className="w-7 h-6 rounded cursor-pointer border border-zinc-700" />
<button
onClick={() => updateText({ backgroundColor: ts.backgroundColor === 'transparent' ? 'rgba(0,0,0,0.7)' : 'transparent' })}
className={`px-1.5 py-0.5 rounded text-[9px] border ${ts.backgroundColor !== 'transparent' ? 'bg-cyan-600/20 text-cyan-300 border-cyan-500/30' : 'bg-zinc-800 text-zinc-500 border-zinc-700'}`}
>
{ts.backgroundColor !== 'transparent' ? 'On' : 'Off'}
</button>
</div>
</div>
{/* Text alignment */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Align</span>
<div className="flex gap-0.5">
{(['left', 'center', 'right'] as const).map(align => (
<button
key={align}
onClick={() => updateText({ textAlign: align })}
className={`p-1.5 rounded ${ts.textAlign === align ? 'bg-cyan-600/30 text-cyan-300' : 'bg-zinc-800 text-zinc-500 hover:text-zinc-300'}`}
>
{align === 'left' ? <AlignLeft className="h-3 w-3" /> : align === 'center' ? <AlignCenter className="h-3 w-3" /> : <AlignRight className="h-3 w-3" />}
</button>
))}
</div>
</div>
{/* Position */}
<div className="space-y-1.5">
<span className="text-[10px] text-zinc-400">Position</span>
<div className="flex gap-2">
<div className="flex-1">
<span className="text-[9px] text-zinc-500">X</span>
<input type="range" min={0} max={100} value={ts.positionX} onChange={e => updateText({ positionX: parseFloat(e.target.value) })} className="w-full accent-cyan-500" />
</div>
<div className="flex-1">
<span className="text-[9px] text-zinc-500">Y</span>
<input type="range" min={0} max={100} value={ts.positionY} onChange={e => updateText({ positionY: parseFloat(e.target.value) })} className="w-full accent-cyan-500" />
</div>
</div>
</div>
{/* Opacity */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Opacity</span>
<div className="flex items-center gap-2">
<input type="range" min={0} max={100} value={ts.opacity} onChange={e => updateText({ opacity: parseInt(e.target.value) })} className="w-20 accent-cyan-500" />
<span className="text-[10px] text-zinc-300 w-8 text-right tabular-nums">{ts.opacity}%</span>
</div>
</div>
{/* Stroke */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Outline</span>
<div className="flex items-center gap-1.5">
<input type="range" min={0} max={10} step={0.5} value={ts.strokeWidth} onChange={e => updateText({ strokeWidth: parseFloat(e.target.value) })} className="w-16 accent-cyan-500" />
<input type="color" value={ts.strokeColor === 'transparent' ? '#000000' : ts.strokeColor} onChange={e => updateText({ strokeColor: e.target.value, strokeWidth: Math.max(ts.strokeWidth, 1) })} className="w-5 h-5 rounded cursor-pointer border border-zinc-700" />
</div>
</div>
{/* Shadow */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Shadow</span>
<div className="flex items-center gap-2">
<input type="range" min={0} max={20} value={ts.shadowBlur} onChange={e => updateText({ shadowBlur: parseInt(e.target.value) })} className="w-16 accent-cyan-500" />
<span className="text-[10px] text-zinc-300 w-4 text-right tabular-nums">{ts.shadowBlur}</span>
</div>
</div>
{/* Presets */}
<div className="pt-2 border-t border-zinc-800">
<span className="text-[10px] text-zinc-400 block mb-1.5">Apply Preset</span>
<div className="grid grid-cols-2 gap-1">
{TEXT_PRESETS.map(preset => (
<button
key={preset.id}
onClick={() => updateText({ ...preset.style })}
className="px-2 py-1.5 rounded bg-zinc-800 border border-zinc-700 text-[9px] text-zinc-300 hover:border-cyan-500/40 hover:bg-cyan-900/20 transition-colors truncate"
title={preset.name}
>
{preset.name}
</button>
))}
</div>
</div>
</div>
)
})()}
{/* Image-to-Video quick action for image clips */}
{selectedClip.type === 'image' && (
<button
onClick={() => onCreateVideoFromImage(selectedClip)}
className="w-full px-3 py-2 rounded-lg bg-blue-600/15 border border-blue-500/30 text-blue-400 text-xs hover:bg-blue-600/25 hover:border-blue-500/50 transition-colors font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Film className="h-3.5 w-3.5" />
Generate Video (I2V)
</button>
)}
<div>
<label className="block text-xs text-zinc-500 mb-1">Start Time</label>
<div className="flex items-center gap-2">
<input
type="number"
value={selectedClip.startTime.toFixed(2)}
onChange={(e) => updateClip(selectedClip.id, { startTime: Math.max(0, parseFloat(e.target.value) || 0) })}
min={0}
step={0.1}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
<span className="text-xs text-zinc-500">sec</span>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Duration</label>
<div className="flex items-center gap-2">
<input
type="number"
value={selectedClip.duration.toFixed(2)}
onChange={(e) => {
let dur = Math.max(0.1, parseFloat(e.target.value) || 1)
const maxDur = getMaxClipDuration(selectedClip)
dur = Math.min(dur, maxDur)
updateClip(selectedClip.id, { duration: dur })
}}
min={0.1}
max={getMaxClipDuration(selectedClip)}
step={0.1}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
<span className="text-xs text-zinc-500">sec</span>
{selectedClip.type === 'video' && selectedClip.asset?.duration && (
<span className="text-[10px] text-zinc-600">max {getMaxClipDuration(selectedClip).toFixed(1)}s</span>
)}
</div>
</div>
{hasPlaybackControls && (
<div>
<label className="block text-xs text-zinc-500 mb-1">Speed</label>
<input
type="range"
min={0.25}
max={4}
step={0.25}
value={selectedClip.speed}
onChange={(e) => {
const newSpeed = parseFloat(e.target.value)
const oldSpeed = selectedClip.speed
let newDuration = selectedClip.duration * (oldSpeed / newSpeed)
const maxDur = getMaxClipDuration({ ...selectedClip, speed: newSpeed })
newDuration = Math.min(newDuration, maxDur)
newDuration = Math.max(0.5, newDuration)
updateClip(selectedClip.id, { speed: newSpeed, duration: newDuration })
}}
className="w-full"
/>
<div className="flex justify-between text-[10px] text-zinc-500 mt-1">
<span>0.25x</span>
<span className="text-white">{selectedClip.speed}x</span>
<span>4x</span>
</div>
</div>
)}
{hasAudioControls && (
<div>
<label className="block text-xs text-zinc-500 mb-1">Volume</label>
<input
type="range"
min={0}
max={1}
step={0.1}
value={effectiveMuted ? 0 : effectiveVolume}
onChange={(e) => setClipAudioLevel(selectedClip.id, parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-[10px] text-zinc-500 mt-1">
<span>0%</span>
<span className="text-white">{effectiveMuted ? '0' : Math.round(effectiveVolume * 100)}%</span>
<span>100%</span>
</div>
</div>
)}
{hasAudioControls && (
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedClip.reversed}
onChange={(e) => updateClip(selectedClip.id, { reversed: e.target.checked })}
className="rounded bg-zinc-800 border-zinc-600"
/>
<span className="text-sm text-zinc-300">Reverse playback</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={effectiveMuted}
onChange={(e) => setClipAudioMuted(selectedClip.id, e.target.checked)}
className="rounded bg-zinc-800 border-zinc-600"
/>
<span className="text-sm text-zinc-300">Mute audio</span>
</label>
</div>
)}
{/* --- Opacity --- */}
{!isTextClip && (
<div className="pt-3 border-t border-zinc-800">
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-zinc-400">Opacity</label>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.opacity ?? 100}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={selectedClip.opacity ?? 100}
onChange={(e) => updateClip(selectedClip.id, { opacity: parseInt(e.target.value) })}
className="w-full h-1.5 accent-blue-500"
/>
<div className="flex justify-between text-[9px] text-zinc-600 mt-0.5">
<span>0%</span>
<span>100%</span>
</div>
</div>
)}
{/* --- Flip --- */}
{hasVisualTransformControls && (
<div className="pt-3 border-t border-zinc-800">
<button
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-zinc-400 hover:text-white transition-colors mb-2"
onClick={() => setShowFlip(!showFlip)}
>
{showFlip ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<FlipHorizontal2 className="h-3.5 w-3.5" />
Flip
</button>
{showFlip && (
<div className="space-y-2 pl-5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedClip.flipH}
onChange={(e) => updateClip(selectedClip.id, { flipH: e.target.checked })}
className="rounded bg-zinc-800 border-zinc-600"
/>
<FlipHorizontal2 className="h-3.5 w-3.5 text-zinc-400" />
<span className="text-sm text-zinc-300">Horizontal</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedClip.flipV}
onChange={(e) => updateClip(selectedClip.id, { flipV: e.target.checked })}
className="rounded bg-zinc-800 border-zinc-600"
/>
<FlipVertical2 className="h-3.5 w-3.5 text-zinc-400" />
<span className="text-sm text-zinc-300">Vertical</span>
</label>
</div>
)}
</div>
)}
{/* --- Transitions --- */}
{hasTransitionControls && (
<div className="pt-3 border-t border-zinc-800">
<button
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-zinc-400 hover:text-white transition-colors mb-2"
onClick={() => setShowTransitions(!showTransitions)}
>
{showTransitions ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Film className="h-3.5 w-3.5" />
Transitions
</button>
{showTransitions && (
<div className="space-y-3 pl-5">
{/* Transition In */}
<div>
<label className="block text-[10px] text-zinc-500 mb-1 uppercase tracking-wider">Transition In</label>
<select
value={selectedClip.transitionIn?.type || 'none'}
onChange={(e) => updateClip(selectedClip.id, {
transitionIn: { ...selectedClip.transitionIn, type: e.target.value as TransitionType }
})}
className="w-full px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs"
>
<option value="none">None</option>
<option value="dissolve">Dissolve</option>
<option value="fade-to-black">Fade from Black</option>
<option value="fade-to-white">Fade from White</option>
<option value="wipe-left">Wipe Left</option>
<option value="wipe-right">Wipe Right</option>
<option value="wipe-up">Wipe Up</option>
<option value="wipe-down">Wipe Down</option>
</select>
{selectedClip.transitionIn?.type !== 'none' && (
<div className="mt-1.5">
<label className="block text-[10px] text-zinc-600 mb-0.5">Duration</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={Math.min(2, selectedClip.duration / 2)}
step={0.1}
value={selectedClip.transitionIn?.duration || 0.5}
onChange={(e) => updateClip(selectedClip.id, {
transitionIn: { ...selectedClip.transitionIn, duration: parseFloat(e.target.value) }
})}
className="flex-1"
/>
<span className="text-[10px] text-zinc-400 w-6 text-right">{(selectedClip.transitionIn?.duration || 0.5).toFixed(1)}s</span>
</div>
</div>
)}
</div>
{/* Transition Out */}
<div>
<label className="block text-[10px] text-zinc-500 mb-1 uppercase tracking-wider">Transition Out</label>
<select
value={selectedClip.transitionOut?.type || 'none'}
onChange={(e) => updateClip(selectedClip.id, {
transitionOut: { ...selectedClip.transitionOut, type: e.target.value as TransitionType }
})}
className="w-full px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs"
>
<option value="none">None</option>
<option value="dissolve">Dissolve</option>
<option value="fade-to-black">Fade to Black</option>
<option value="fade-to-white">Fade to White</option>
<option value="wipe-left">Wipe Left</option>
<option value="wipe-right">Wipe Right</option>
<option value="wipe-up">Wipe Up</option>
<option value="wipe-down">Wipe Down</option>
</select>
{selectedClip.transitionOut?.type !== 'none' && (
<div className="mt-1.5">
<label className="block text-[10px] text-zinc-600 mb-0.5">Duration</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={Math.min(2, selectedClip.duration / 2)}
step={0.1}
value={selectedClip.transitionOut?.duration || 0.5}
onChange={(e) => updateClip(selectedClip.id, {
transitionOut: { ...selectedClip.transitionOut, duration: parseFloat(e.target.value) }
})}
className="flex-1"
/>
<span className="text-[10px] text-zinc-400 w-6 text-right">{(selectedClip.transitionOut?.duration || 0.5).toFixed(1)}s</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* EFFECTS HIDDEN - Applied Effects section hidden because effects are not applied during export */}
{/* --- Color Correction --- */}
{hasColorCorrectionControls && (
<div className="pt-3 border-t border-zinc-800">
<button
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-zinc-400 hover:text-white transition-colors mb-2"
onClick={() => setShowColorCorrection(!showColorCorrection)}
>
{showColorCorrection ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Palette className="h-3.5 w-3.5" />
Color Correction
{selectedClip.colorCorrection && Object.values(selectedClip.colorCorrection).some(v => v !== 0) && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</button>
{showColorCorrection && (
<div className="space-y-2.5 pl-1">
<button
className="flex items-center gap-1.5 text-[10px] text-zinc-500 hover:text-blue-400 transition-colors"
onClick={() => updateClip(selectedClip.id, { colorCorrection: { ...DEFAULT_COLOR_CORRECTION } })}
>
<RotateCcw className="h-3 w-3" />
Reset All
</button>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Eye className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Exposure</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.exposure || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.exposure || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), exposure: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Sun className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Brightness</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.brightness || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.brightness || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), brightness: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Contrast className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Contrast</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.contrast || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.contrast || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), contrast: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Droplets className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Saturation</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.saturation || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.saturation || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), saturation: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Thermometer className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Temperature</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.temperature || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.temperature || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), temperature: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
<div className="flex justify-between text-[9px] text-zinc-600 mt-0.5">
<span>Cool</span>
<span>Warm</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Palette className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Tint</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.tint || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.tint || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), tint: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
<div className="flex justify-between text-[9px] text-zinc-600 mt-0.5">
<span>Green</span>
<span>Magenta</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<SunDim className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Highlights</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.highlights || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.highlights || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), highlights: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Moon className="h-3 w-3 text-zinc-500" />
<span className="text-[11px] text-zinc-400">Shadows</span>
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">{selectedClip.colorCorrection?.shadows || 0}</span>
</div>
<input
type="range"
min={-100}
max={100}
step={1}
value={selectedClip.colorCorrection?.shadows || 0}
onChange={(e) => updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), shadows: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
</div>
</div>
)}
</div>
)}
</div>}
</div>
)
}