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 (
{/* Tab header */}
{/* 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 (
{/* Currently Displayed */}
Currently Displayed
Take
{displayTakeNum} / {totalTakes}
{totalTakes > 1 && (
)}
{dims ? (
<>
Quality
{qualityTier >= 2160 ? 'Ultra HD' : qualityTier >= 1080 ? 'Full HD' : qualityTier >= 720 ? 'HD' : 'SD'}
{isUpscaled && (Upscaled)}
{dims.width > 0 && (
Dimensions
{dims.width} × {dims.height}
)}
>
) : (
Dimension metadata unavailable.
)}
{originalRes && originalRes !== 'imported' && (
Original Gen
{originalRes}
)}
{/* Clip Info */}
Clip Info
Type
{selectedClip.type === 'video' && }
{selectedClip.type === 'image' && }
{selectedClip.type === 'audio' && }
{selectedClip.type === 'text' && }
{selectedClip.type}
Duration
{selectedClip.duration.toFixed(2)}s
{liveAsset?.duration && (
Source Duration
{liveAsset.duration.toFixed(2)}s
)}
Speed
{selectedClip.speed}x
Track
{tracks[selectedClip.trackIndex]?.name || `Track ${selectedClip.trackIndex + 1}`}
Start
{formatTime(selectedClip.startTime)}
End
{formatTime(selectedClip.startTime + selectedClip.duration)}
Trim In
{selectedClip.trimStart.toFixed(2)}s
Trim Out
{selectedClip.trimEnd.toFixed(2)}s
Opacity
{selectedClip.opacity}%
{/* Takes */}
{liveAsset?.takes && liveAsset.takes.length > 1 && (
Takes
Total Takes
{liveAsset.takes.length}
Active Take
#{(selectedClip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)) + 1}
)}
{/* Generation Parameters */}
{genParams && (
Generation
Mode
{genParams.mode.replace(/-/g, ' ')}
Model
{genParams.model}
Gen Resolution
{genParams.resolution}
FPS
{genParams.fps}
Duration
{genParams.duration}s
{genParams.cameraMotion && genParams.cameraMotion !== 'none' && (
Camera
{genParams.cameraMotion}
)}
{genParams.prompt && (
Prompt
{genParams.prompt}
)}
)}
{/* File Path */}
{filePath && (
)}
{/* Asset Created At */}
{liveAsset?.createdAt && (
Created
{new Date(liveAsset.createdAt).toLocaleString()}
)}
)
})()}
{/* Properties Tab */}
{propertiesTab === 'properties' &&
{/* Adjustment Layer properties */}
{selectedClip.type === 'adjustment' && (() => {
const lb = { ...DEFAULT_LETTERBOX, ...selectedClip.letterbox }
const updateLetterbox = (patch: Partial
) => {
updateClip(selectedClip.id, { letterbox: { ...lb, ...patch } })
}
return (
Adjustment Layer
{/* Letterbox toggle */}
Letterbox
{lb.enabled && (
<>
{/* Aspect ratio */}
Aspect Ratio
{/* Custom ratio input */}
{lb.aspectRatio === 'custom' && (
Custom Ratio
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"
/>
)}
{/* Bar color */}
Bar Color
updateLetterbox({ color: e.target.value })}
className="w-7 h-6 rounded cursor-pointer border border-zinc-700"
/>
{/* Bar opacity */}
>
)}
{/* Color correction note */}
Color correction on this layer affects all tracks below.
)
})()}
{/* Text overlay properties */}
{selectedClip.type === 'text' && selectedClip.textStyle && (() => {
const ts = selectedClip.textStyle
const updateText = (patch: Partial) => {
updateClip(selectedClip.id, { textStyle: { ...ts, ...patch } })
}
return (
Text Overlay
{/* Text content */}
Content
{/* Font family */}
Font
{/* Font size */}
{/* Font weight & style */}
Weight
{/* Text color */}
Color
updateText({ color: e.target.value })} className="w-7 h-6 rounded cursor-pointer border border-zinc-700" />
{/* Background color */}
{/* Text alignment */}
Align
{(['left', 'center', 'right'] as const).map(align => (
))}
{/* Position */}
{/* Opacity */}
{/* Stroke */}
{/* Shadow */}
{/* Presets */}
Apply Preset
{TEXT_PRESETS.map(preset => (
))}
)
})()}
{/* Image-to-Video quick action for image clips */}
{selectedClip.type === 'image' && (
)}
{hasPlaybackControls && (
)}
{hasAudioControls && (
)}
{hasAudioControls && (
)}
{/* --- Opacity --- */}
{!isTextClip && (
{selectedClip.opacity ?? 100}%
updateClip(selectedClip.id, { opacity: parseInt(e.target.value) })}
className="w-full h-1.5 accent-blue-500"
/>
0%
100%
)}
{/* --- Flip --- */}
{hasVisualTransformControls && (
)}
{/* --- Transitions --- */}
{hasTransitionControls && (
{showTransitions && (
{/* Transition In */}
{selectedClip.transitionIn?.type !== 'none' && (
)}
{/* Transition Out */}
{selectedClip.transitionOut?.type !== 'none' && (
)}
)}
)}
{/* EFFECTS HIDDEN - Applied Effects section hidden because effects are not applied during export */}
{/* --- Color Correction --- */}
{hasColorCorrectionControls && (
{showColorCorrection && (
Temperature
{selectedClip.colorCorrection?.temperature || 0}
updateClip(selectedClip.id, {
colorCorrection: { ...(selectedClip.colorCorrection || DEFAULT_COLOR_CORRECTION), temperature: parseInt(e.target.value) }
})}
className="w-full h-1.5 accent-blue-500"
/>
Cool
Warm
)}
)}
}
)
}