import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { FolderPlus, Folder, Upload, ChevronLeft, ChevronDown, ChevronRight, ChevronUp, X, RefreshCw, Loader2, Trash2, Music, Layers, Video, Image, LayoutGrid, List, ArrowUpDown, Pencil, } from 'lucide-react' import { shallow } from 'zustand/vanilla/shallow' import { createAssetBinId, type Asset } from '../../types/project-model' import { VideoThumbnailCard } from './VideoThumbnailCard' import { getColorLabel } from './video-editor-utils' import { Tooltip } from '../../components/ui/tooltip' import { AssetContextMenu } from './AssetContextMenu' import { TakeContextMenu } from './TakeContextMenu' import { pathToFileUrl } from '../../lib/file-url' import type { AssetListFilters } from './editor-state' import { equalAssetBins, selectAssetBins, selectAssets, selectVisibleAssets } from './editor-selectors' import { useEditorActions, useEditorStore } from './editor-store' export interface VideoEditorAssetsPanelHandle { revealAsset: (assetId: string) => void deleteAsset: (target?: string | string[]) => void } export interface VideoEditorAssetsPanelProps { openSourceAsset: (asset: Asset) => void handleImportFile: (e: React.ChangeEvent) => void handleRegenerate: (assetId: string) => void handleCancelRegeneration: () => void canCancelInFlight: boolean isRegenerating: boolean regeneratingAssetId: string | null regenProgress: number regenStatusMessage: string } export const VideoEditorAssetsPanel = forwardRef(function VideoEditorAssetsPanel(props, ref) { const { openSourceAsset, handleImportFile, handleRegenerate, handleCancelRegeneration, canCancelInFlight, isRegenerating, regeneratingAssetId, regenProgress, regenStatusMessage, } = props const actions = useEditorActions() const assets = useEditorStore(selectAssets) const [takesViewAssetId, setTakesViewAssetId] = useState(null) const [creatingBin, setCreatingBin] = useState(false) const [renamingBinId, setRenamingBinId] = useState(null) const [newBinName, setNewBinName] = useState('') const [selectedBinId, setSelectedBinId] = useState(null) const [assetFilter, setAssetFilter] = useState<'all' | 'video' | 'image' | 'audio'>('all') const [selectedAssetIds, setSelectedAssetIds] = useState>(new Set()) const [assetLasso, setAssetLasso] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null) const [assetContextMenu, setAssetContextMenu] = useState<{ assetId: string; x: number; y: number } | null>(null) const [takeContextMenu, setTakeContextMenu] = useState<{ assetId: string; takeIndex: number; x: number; y: number } | null>(null) const [binContextMenu, setBinContextMenu] = useState<{ binId: string; x: number; y: number } | null>(null) const [assetViewMode, setAssetViewMode] = useState<'grid' | 'list'>('grid') const [listSortCol, setListSortCol] = useState<'name' | 'type' | 'duration' | 'resolution' | 'date' | 'color'>('name') const [listSortDir, setListSortDir] = useState<'asc' | 'desc'>('asc') const assetLassoActive = assetLasso !== null const fileInputRef = useRef(null) const assetGridRef = useRef(null) const newBinInputRef = useRef(null) const assetContextMenuRef = useRef(null) const takeContextMenuRef = useRef(null) const binContextMenuRef = useRef(null) const assetLassoRef = useRef(assetLasso) assetLassoRef.current = assetLasso const assetLassoPointerRef = useRef<{ clientX: number; clientY: number } | null>(null) const assetLassoBaseSelectionRef = useRef>(new Set()) const assetLassoAutoScrollFrameRef = useRef(null) const bins = useEditorStore(selectAssetBins, equalAssetBins) const binIdToName = useMemo( () => new Map(bins.map(bin => [bin.id, bin.name])), [bins], ) const assetFilters: AssetListFilters = { assetFilter, selectedBinId, assetViewMode, listSortCol, listSortDir, } const visibleAssets = useEditorStore(state => selectVisibleAssets(state, assetFilters), shallow) const filteredAssets = assetViewMode === 'list' ? assets.filter(asset => visibleAssets.some(visible => visible.id === asset.id)) : visibleAssets const deleteAsset = useCallback((target?: string | string[]) => { const rawIds = target === undefined ? [...selectedAssetIds] : typeof target === 'string' ? [target] : target const ids = Array.from(new Set(rawIds.filter(Boolean))) if (ids.length === 0) return actions.deleteAssets(ids) setSelectedAssetIds(prev => { if (prev.size === 0) return prev const next = new Set(prev) ids.forEach(id => next.delete(id)) return next }) if (takesViewAssetId && ids.includes(takesViewAssetId)) { setTakesViewAssetId(null) } }, [actions, selectedAssetIds, takesViewAssetId]) const revealAsset = useCallback((assetId: string) => { const asset = assets.find(a => a.id === assetId) if (!asset) return setAssetFilter('all') setSelectedBinId(asset.binId ?? null) setTakesViewAssetId(null) setSelectedAssetIds(new Set([asset.id])) setTimeout(() => { const card = assetGridRef.current?.querySelector(`[data-asset-id="${asset.id}"]`) card?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 100) }, [assets]) useImperativeHandle(ref, () => ({ revealAsset, deleteAsset, }), [deleteAsset, revealAsset]) const setAssetActiveTake = useCallback((assetId: string, takeIndex: number) => { actions.setAssetActiveTake(assetId, takeIndex) }, [actions]) const addClipToTimeline = useCallback((asset: Asset, trackIndex = 0, startTime?: number) => { actions.insertAssetsToTimeline({ assets: [asset], trackIndex, startTime }) }, [actions]) const createAssetFromTake = useCallback((asset: Asset, take: NonNullable[number]): Asset => ({ id: `asset-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, type: asset.type, path: take.path, bigThumbnailPath: take.bigThumbnailPath, smallThumbnailPath: take.smallThumbnailPath, width: take.width, height: take.height, prompt: asset.prompt, resolution: asset.resolution, duration: asset.duration, binId: asset.binId, generationParams: asset.generationParams, takes: [{ path: take.path, bigThumbnailPath: take.bigThumbnailPath, smallThumbnailPath: take.smallThumbnailPath, width: take.width, height: take.height, createdAt: take.createdAt, }], activeTakeIndex: 0, createdAt: Date.now(), }), []) const openCreateBinEditor = useCallback((assetIds?: string[]) => { if (assetIds) { setSelectedAssetIds(new Set(assetIds)) } setRenamingBinId(null) setCreatingBin(true) setNewBinName('') }, []) const commitBinEdit = useCallback(() => { const trimmedName = newBinName.trim() if (renamingBinId) { const currentBinName = binIdToName.get(renamingBinId) if (trimmedName && trimmedName !== currentBinName) { actions.renameBin(renamingBinId, trimmedName) } setRenamingBinId(null) setNewBinName('') return } if (!trimmedName) { setCreatingBin(false) setNewBinName('') return } const existingBin = bins.find(bin => bin.name === trimmedName) const binId = existingBin?.id ?? createAssetBinId() if (!existingBin) { actions.createBin(binId, trimmedName) } if (selectedAssetIds.size > 0) { actions.assignAssetsToBin([...selectedAssetIds], binId) setSelectedAssetIds(new Set()) } setSelectedBinId(binId) setCreatingBin(false) setNewBinName('') }, [actions, binIdToName, bins, newBinName, renamingBinId, selectedAssetIds]) useEffect(() => { if (!takesViewAssetId) return const takesAsset = assets.find(a => a.id === takesViewAssetId) if (!takesAsset || !takesAsset.takes || takesAsset.takes.length <= 1) { setTakesViewAssetId(null) } }, [assets, takesViewAssetId]) useEffect(() => { if (!creatingBin && !renamingBinId) return setTimeout(() => newBinInputRef.current?.focus(), 0) }, [creatingBin, renamingBinId]) useEffect(() => { if (!assetContextMenu) return const handler = () => setAssetContextMenu(null) window.addEventListener('click', handler) return () => window.removeEventListener('click', handler) }, [assetContextMenu]) useEffect(() => { if (!assetContextMenu || !assetContextMenuRef.current) return const el = assetContextMenuRef.current const rect = el.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight let { x, y } = assetContextMenu let adjusted = false if (rect.right > vw - 8) { x = vw - rect.width - 8; adjusted = true } if (rect.bottom > vh - 8) { y = vh - rect.height - 8; adjusted = true } if (x < 8) { x = 8; adjusted = true } if (y < 8) { y = 8; adjusted = true } if (adjusted) { el.style.left = `${x}px` el.style.top = `${y}px` } }, [assetContextMenu]) useEffect(() => { if (!takeContextMenu) return const handler = () => setTakeContextMenu(null) window.addEventListener('click', handler) return () => window.removeEventListener('click', handler) }, [takeContextMenu]) useEffect(() => { if (!takeContextMenu || !takeContextMenuRef.current) return const el = takeContextMenuRef.current const rect = el.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight let { x, y } = takeContextMenu let adjusted = false if (rect.right > vw - 8) { x = vw - rect.width - 8; adjusted = true } if (rect.bottom > vh - 8) { y = vh - rect.height - 8; adjusted = true } if (x < 8) { x = 8; adjusted = true } if (y < 8) { y = 8; adjusted = true } if (adjusted) { el.style.left = `${x}px` el.style.top = `${y}px` } }, [takeContextMenu]) useEffect(() => { if (!binContextMenu) return const handler = () => setBinContextMenu(null) window.addEventListener('click', handler) return () => window.removeEventListener('click', handler) }, [binContextMenu]) useEffect(() => { if (!binContextMenu || !binContextMenuRef.current) return const el = binContextMenuRef.current const rect = el.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight let { x, y } = binContextMenu let adjusted = false if (rect.right > vw - 8) { x = vw - rect.width - 8; adjusted = true } if (rect.bottom > vh - 8) { y = vh - rect.height - 8; adjusted = true } if (x < 8) { x = 8; adjusted = true } if (y < 8) { y = 8; adjusted = true } if (adjusted) { el.style.left = `${x}px` el.style.top = `${y}px` } }, [binContextMenu]) const toggleSort = (col: typeof listSortCol) => { if (listSortCol === col) { setListSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setListSortCol(col) setListSortDir('asc') } } const updateAssetLassoSelection = useCallback((clientX: number, clientY: number) => { const currentLasso = assetLassoRef.current const container = assetGridRef.current if (!currentLasso || !container) return const rect = container.getBoundingClientRect() const scrollTop = container.scrollTop const x = clientX - rect.left const y = clientY - rect.top + scrollTop setAssetLasso({ ...currentLasso, currentX: x, currentY: y, }) const lassoLeft = Math.min(currentLasso.startX, x) const lassoRight = Math.max(currentLasso.startX, x) const lassoTop = Math.min(currentLasso.startY, y) const lassoBottom = Math.max(currentLasso.startY, y) const nextSelected = new Set(assetLassoBaseSelectionRef.current) const cards = container.querySelectorAll('[data-asset-card]') cards.forEach(card => { const cardRect = card.getBoundingClientRect() const cardLeft = cardRect.left - rect.left const cardRight = cardRect.right - rect.left const cardTop = cardRect.top - rect.top + scrollTop const cardBottom = cardRect.bottom - rect.top + scrollTop if (cardLeft < lassoRight && cardRight > lassoLeft && cardTop < lassoBottom && cardBottom > lassoTop) { const id = card.dataset.assetId if (id) nextSelected.add(id) } }) setSelectedAssetIds(nextSelected) }, []) const tickAssetLassoAutoScroll = useCallback(() => { assetLassoAutoScrollFrameRef.current = null const currentLasso = assetLassoRef.current const pointer = assetLassoPointerRef.current const container = assetGridRef.current if (!currentLasso || !pointer || !container) return const rect = container.getBoundingClientRect() const edgeThreshold = 28 const maxStep = 18 let scrollDelta = 0 if (pointer.clientY < rect.top + edgeThreshold) { const distance = rect.top + edgeThreshold - pointer.clientY scrollDelta = -Math.min(maxStep, Math.max(4, distance * 0.35)) } else if (pointer.clientY > rect.bottom - edgeThreshold) { const distance = pointer.clientY - (rect.bottom - edgeThreshold) scrollDelta = Math.min(maxStep, Math.max(4, distance * 0.35)) } if (scrollDelta !== 0) { const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) const nextScrollTop = Math.max(0, Math.min(maxScrollTop, container.scrollTop + scrollDelta)) if (nextScrollTop !== container.scrollTop) { container.scrollTop = nextScrollTop updateAssetLassoSelection(pointer.clientX, pointer.clientY) } } if (assetLassoRef.current) { assetLassoAutoScrollFrameRef.current = requestAnimationFrame(tickAssetLassoAutoScroll) } }, [updateAssetLassoSelection]) useEffect(() => { if (!assetLassoActive) return const handleMouseMove = (event: MouseEvent) => { assetLassoPointerRef.current = { clientX: event.clientX, clientY: event.clientY } updateAssetLassoSelection(event.clientX, event.clientY) } const handleMouseUp = () => { setAssetLasso(null) assetLassoPointerRef.current = null } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) assetLassoAutoScrollFrameRef.current = requestAnimationFrame(tickAssetLassoAutoScroll) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) if (assetLassoAutoScrollFrameRef.current !== null) { cancelAnimationFrame(assetLassoAutoScrollFrameRef.current) assetLassoAutoScrollFrameRef.current = null } } }, [assetLassoActive, tickAssetLassoAutoScroll, updateAssetLassoSelection]) return (
{!takesViewAssetId ? ( <>

Assets

{(['all', 'video', 'image', 'audio'] as const).map(filter => ( ))}
{(bins.length > 0 || creatingBin) && (
{bins.map(bin => ( ))} {(creatingBin || renamingBinId) && (
setNewBinName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') commitBinEdit() if (e.key === 'Escape') { setCreatingBin(false) setRenamingBinId(null) setNewBinName('') } }} onBlur={commitBinEdit} placeholder="Bin name..." className="w-20 px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 border border-zinc-600 text-white placeholder-zinc-600 focus:outline-none focus:border-blue-500" />
)}
)} ) : (

Takes

)}
{takesViewAssetId ? ( (() => { const takesAsset = assets.find(asset => asset.id === takesViewAssetId) if (!takesAsset?.takes || takesAsset.takes.length <= 1) return null const takes = takesAsset.takes return (

{takesAsset.prompt?.slice(0, 40) || 'Asset'}{(takesAsset.prompt?.length ?? 0) > 40 ? '...' : ''}

{takes.length} takes

{takesAsset.generationParams && ( isRegenerating && regeneratingAssetId === takesAsset.id && canCancelInFlight ? ( ) : ( ) )}
{takes.map((take, idx) => { const isActive = (takesAsset.activeTakeIndex ?? 0) === idx return (
{ setAssetActiveTake(takesAsset.id, idx) }} onDoubleClick={() => { setAssetActiveTake(takesAsset.id, idx) openSourceAsset({ ...takesAsset, path: take.path, bigThumbnailPath: take.bigThumbnailPath, smallThumbnailPath: take.smallThumbnailPath, }) }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() setTakeContextMenu({ assetId: takesAsset.id, takeIndex: idx, x: e.clientX, y: e.clientY }) }} > {takesAsset.type === 'video' ? ( ) : ( take.smallThumbnailPath ? ( ) : (
) )} {isActive &&
}
Take {idx + 1}
{isActive && (
Active
)}
{new Date(take.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{takes.length > 1 && ( )} {isRegenerating && regeneratingAssetId === takesAsset.id && idx === takes.length - 1 && (
{regenProgress}% {regenStatusMessage} {canCancelInFlight && ( )}
)}
) })}
) })() ) : (
{ if ((e.target as HTMLElement).closest('[data-asset-card]')) return if (e.button !== 0) return const container = assetGridRef.current const rect = container?.getBoundingClientRect() if (!rect) return const additiveSelection = e.ctrlKey || e.metaKey || e.shiftKey const scrollTop = container?.scrollTop || 0 const x = e.clientX - rect.left const y = e.clientY - rect.top + scrollTop setAssetLasso({ startX: x, startY: y, currentX: x, currentY: y }) assetLassoPointerRef.current = { clientX: e.clientX, clientY: e.clientY } assetLassoBaseSelectionRef.current = additiveSelection ? new Set(selectedAssetIds) : new Set() if (!additiveSelection) setSelectedAssetIds(new Set()) }} > {assetLasso && (() => { const left = Math.min(assetLasso.startX, assetLasso.currentX) const top = Math.min(assetLasso.startY, assetLasso.currentY) const width = Math.abs(assetLasso.currentX - assetLasso.startX) const height = Math.abs(assetLasso.currentY - assetLasso.startY) if (width < 3 && height < 3) return null return (
) })()} {filteredAssets.length === 0 ? (

No assets yet

Generate in Gen Space or import

) : assetViewMode === 'grid' ? (
{filteredAssets.map(asset => { const cl = getColorLabel(asset.colorLabel) return (
{ if (selectedAssetIds.size > 0 && selectedAssetIds.has(asset.id)) { e.dataTransfer.setData('assetIds', JSON.stringify([...selectedAssetIds])) } else { e.dataTransfer.setData('assetId', asset.id) } e.dataTransfer.setData('asset', JSON.stringify(asset)) e.dataTransfer.effectAllowed = 'copy' }} onClick={(e) => { e.stopPropagation() if (e.ctrlKey || e.metaKey) { setSelectedAssetIds(prev => { const next = new Set(prev) if (next.has(asset.id)) next.delete(asset.id) else next.add(asset.id) return next }) } else if (e.shiftKey && selectedAssetIds.size > 0) { const lastId = [...selectedAssetIds].pop() const lastIdx = filteredAssets.findIndex(a => a.id === lastId) const thisIdx = filteredAssets.findIndex(a => a.id === asset.id) if (lastIdx >= 0 && thisIdx >= 0) { const start = Math.min(lastIdx, thisIdx) const end = Math.max(lastIdx, thisIdx) const next = new Set(selectedAssetIds) for (let i = start; i <= end; i++) next.add(filteredAssets[i].id) setSelectedAssetIds(next) } } else if (selectedAssetIds.has(asset.id) && selectedAssetIds.size === 1) { setSelectedAssetIds(new Set()) } else { setSelectedAssetIds(new Set([asset.id])) } }} onDoubleClick={(e) => { e.stopPropagation() openSourceAsset(asset) }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() if (!selectedAssetIds.has(asset.id)) { setSelectedAssetIds(new Set([asset.id])) } setAssetContextMenu({ assetId: asset.id, x: e.clientX, y: e.clientY }) }} > {cl && ( <>
)} {asset.type === 'video' ? ( ) : asset.type === 'audio' ? (
{[3, 5, 8, 6, 9, 4, 7, 5, 3, 6, 8, 4].map((h, i) => (
))}

{asset.path || 'Audio'}

) : asset.type === 'adjustment' ? (

Adjustment Layer

) : ( asset.smallThumbnailPath ? ( ) : (
) )} {selectedAssetIds.has(asset.id) &&
} {!selectedAssetIds.has(asset.id) && (
)}
{asset.generationParams && ( )}
{isRegenerating && regeneratingAssetId === asset.id && (
{regenProgress}% {regenStatusMessage} {canCancelInFlight && ( )}
)} {asset.takes && asset.takes.length > 1 && (
)} {asset.binId && binIdToName.get(asset.binId) && (
{binIdToName.get(asset.binId)}
)}
{asset.type === 'video' ?
) })}
) : (
{([ { col: 'name' as const, label: 'Name', flex: 'flex-1 min-w-0' }, { col: 'type' as const, label: 'Type', flex: 'w-14 flex-shrink-0 text-center' }, { col: 'duration' as const, label: 'Duration', flex: 'w-16 flex-shrink-0 text-right' }, { col: 'resolution' as const, label: 'Res', flex: 'w-14 flex-shrink-0 text-right' }, { col: 'date' as const, label: 'Date', flex: 'w-16 flex-shrink-0 text-right' }, { col: 'color' as const, label: 'Color', flex: 'w-10 flex-shrink-0 text-center' }, ]).map(({ col, label, flex }) => ( ))}
{visibleAssets.map(asset => { const cl = getColorLabel(asset.colorLabel) const name = asset.path ? asset.path.split(/[/\\]/).pop() || asset.path : asset.type === 'adjustment' ? 'Adjustment Layer' : asset.type.charAt(0).toUpperCase() + asset.type.slice(1) const dateStr = new Date(asset.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) return (
{ if (selectedAssetIds.size > 0 && selectedAssetIds.has(asset.id)) { e.dataTransfer.setData('assetIds', JSON.stringify([...selectedAssetIds])) } else { e.dataTransfer.setData('assetId', asset.id) } e.dataTransfer.setData('asset', JSON.stringify(asset)) e.dataTransfer.effectAllowed = 'copy' }} onClick={(e) => { e.stopPropagation() if (e.ctrlKey || e.metaKey) { setSelectedAssetIds(prev => { const next = new Set(prev) if (next.has(asset.id)) next.delete(asset.id) else next.add(asset.id) return next }) } else if (e.shiftKey && selectedAssetIds.size > 0) { const lastId = [...selectedAssetIds].pop() const lastIdx = filteredAssets.findIndex(a => a.id === lastId) const thisIdx = filteredAssets.findIndex(a => a.id === asset.id) if (lastIdx >= 0 && thisIdx >= 0) { const start = Math.min(lastIdx, thisIdx) const end = Math.max(lastIdx, thisIdx) const next = new Set(selectedAssetIds) for (let i = start; i <= end; i++) next.add(filteredAssets[i].id) setSelectedAssetIds(next) } } else if (selectedAssetIds.has(asset.id) && selectedAssetIds.size === 1) { setSelectedAssetIds(new Set()) } else { setSelectedAssetIds(new Set([asset.id])) } }} onDoubleClick={(e) => { e.stopPropagation() openSourceAsset(asset) }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() if (!selectedAssetIds.has(asset.id)) { setSelectedAssetIds(new Set([asset.id])) } setAssetContextMenu({ assetId: asset.id, x: e.clientX, y: e.clientY }) }} > {cl ? (
) : (
)}
{asset.type === 'video' ? ( asset.smallThumbnailPath ? ( ) : (
) ) : asset.type === 'audio' ? (
) : asset.type === 'adjustment' ? (
) : ( asset.smallThumbnailPath ? ( ) : (
) )}

{name}

{asset.takes && asset.takes.length > 1 && ( {asset.takes.length} takes )}
{asset.type} {asset.duration != null ? `${asset.duration.toFixed(1)}s` : '—'} {asset.resolution || '—'} {dateStr}
{cl ? (
) : ( — )}
) })}
)}
)} {assetContextMenu && (() => { const asset = assets.find(a => a.id === assetContextMenu.assetId) if (!asset) return null const targetIds = selectedAssetIds.size > 0 && selectedAssetIds.has(asset.id) ? [...selectedAssetIds] : [asset.id] return ( ) })()} {takeContextMenu && (() => { const tcAsset = assets.find(a => a.id === takeContextMenu.assetId) if (!tcAsset?.takes) return null const take = tcAsset.takes[takeContextMenu.takeIndex] if (!take) return null return ( ) })()} {binContextMenu && (
e.stopPropagation()} >
)}
) })