Files
LTX-DeskWEB/frontend/views/editor/VideoEditorAssetsPanel.tsx

1209 lines
56 KiB
TypeScript

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<HTMLInputElement>) => void
handleRegenerate: (assetId: string) => void
handleCancelRegeneration: () => void
canCancelInFlight: boolean
isRegenerating: boolean
regeneratingAssetId: string | null
regenProgress: number
regenStatusMessage: string
}
export const VideoEditorAssetsPanel = forwardRef<VideoEditorAssetsPanelHandle, VideoEditorAssetsPanelProps>(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<string | null>(null)
const [creatingBin, setCreatingBin] = useState(false)
const [renamingBinId, setRenamingBinId] = useState<string | null>(null)
const [newBinName, setNewBinName] = useState('')
const [selectedBinId, setSelectedBinId] = useState<string | null>(null)
const [assetFilter, setAssetFilter] = useState<'all' | 'video' | 'image' | 'audio'>('all')
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(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<HTMLInputElement>(null)
const assetGridRef = useRef<HTMLDivElement>(null)
const newBinInputRef = useRef<HTMLInputElement>(null)
const assetContextMenuRef = useRef<HTMLDivElement>(null)
const takeContextMenuRef = useRef<HTMLDivElement>(null)
const binContextMenuRef = useRef<HTMLDivElement>(null)
const assetLassoRef = useRef(assetLasso)
assetLassoRef.current = assetLasso
const assetLassoPointerRef = useRef<{ clientX: number; clientY: number } | null>(null)
const assetLassoBaseSelectionRef = useRef<Set<string>>(new Set())
const assetLassoAutoScrollFrameRef = useRef<number | null>(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<Asset['takes']>[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<HTMLElement>('[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 (
<div className="flex flex-col min-h-0 h-full border-r border-zinc-800">
<div className="p-4 pb-2 space-y-2 flex-shrink-0">
{!takesViewAssetId ? (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Assets</h3>
<div className="flex items-center gap-1">
<Tooltip content="Create bin" side="right">
<button
onClick={() => openCreateBinEditor()}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
>
<FolderPlus className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip content="Import media" side="right">
<button
onClick={() => fileInputRef.current?.click()}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
>
<Upload className="h-4 w-4" />
</button>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex gap-1 bg-zinc-900 rounded-lg p-0.5 flex-1">
{(['all', 'video', 'image', 'audio'] as const).map(filter => (
<button
key={filter}
onClick={() => setAssetFilter(filter)}
className={`flex-1 px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
assetFilter === filter
? 'bg-zinc-800 text-white'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
<div className="flex bg-zinc-900 rounded-lg p-0.5">
<Tooltip content="Grid view" side="right">
<button
onClick={() => setAssetViewMode('grid')}
className={`p-1 rounded transition-colors ${assetViewMode === 'grid' ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
>
<LayoutGrid className="h-3 w-3" />
</button>
</Tooltip>
<Tooltip content="List view" side="right">
<button
onClick={() => setAssetViewMode('list')}
className={`p-1 rounded transition-colors ${assetViewMode === 'list' ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
>
<List className="h-3 w-3" />
</button>
</Tooltip>
</div>
</div>
{(bins.length > 0 || creatingBin) && (
<div className="flex flex-wrap gap-1">
<button
onClick={() => setSelectedBinId(null)}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors flex items-center gap-1 ${
selectedBinId === null
? 'bg-blue-600/30 text-blue-300 border border-blue-500/40'
: 'bg-zinc-800 text-zinc-500 hover:text-zinc-300 border border-transparent'
}`}
>
All
</button>
{bins.map(bin => (
<button
key={bin.id}
onClick={() => setSelectedBinId(selectedBinId === bin.id ? null : bin.id)}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setBinContextMenu({ binId: bin.id, x: e.clientX, y: e.clientY })
}}
onDragOver={(e) => {
e.preventDefault()
e.currentTarget.classList.add('ring-2', 'ring-blue-400')
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('ring-2', 'ring-blue-400')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('ring-2', 'ring-blue-400')
const assetIdsJson = e.dataTransfer.getData('assetIds')
if (assetIdsJson) {
try {
const ids: string[] = JSON.parse(assetIdsJson)
actions.assignAssetsToBin(ids, bin.id)
setSelectedAssetIds(new Set())
} catch {
// ignore parse errors
}
} else {
const assetId = e.dataTransfer.getData('assetId')
if (assetId) actions.assignAssetsToBin([assetId], bin.id)
}
}}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors flex items-center gap-1 group/bin ${
selectedBinId === bin.id
? 'bg-blue-600/30 text-blue-300 border border-blue-500/40'
: 'bg-zinc-800 text-zinc-500 hover:text-zinc-300 border border-transparent'
}`}
>
<Folder className="h-3 w-3" />
{bin.name}
<span className="text-zinc-600 text-[9px]">
{bin.count}
</span>
</button>
))}
{(creatingBin || renamingBinId) && (
<div className="flex items-center gap-1">
<input
ref={newBinInputRef}
type="text"
value={newBinName}
onChange={(e) => 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"
/>
</div>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="video/*,audio/*,image/*"
multiple
onChange={handleImportFile}
className="hidden"
/>
</>
) : (
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Takes</h3>
</div>
)}
</div>
{takesViewAssetId ? (
(() => {
const takesAsset = assets.find(asset => asset.id === takesViewAssetId)
if (!takesAsset?.takes || takesAsset.takes.length <= 1) return null
const takes = takesAsset.takes
return (
<div className="flex-1 overflow-auto p-3 pt-0">
<div className="flex items-center gap-2 mb-3">
<Tooltip content="Back to assets" side="right">
<button
onClick={() => setTakesViewAssetId(null)}
className="p-1 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
</Tooltip>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">
{takesAsset.prompt?.slice(0, 40) || 'Asset'}{(takesAsset.prompt?.length ?? 0) > 40 ? '...' : ''}
</p>
<p className="text-[10px] text-zinc-500">
{takes.length} takes
</p>
</div>
{takesAsset.generationParams && (
isRegenerating && regeneratingAssetId === takesAsset.id && canCancelInFlight ? (
<button
onClick={() => handleCancelRegeneration()}
className="px-2 py-1 rounded-lg bg-red-900/20 text-red-400 hover:bg-red-900/40 transition-colors text-[10px] font-medium flex items-center gap-1 border border-red-500/30"
>
<X className="h-3 w-3" />
Cancel
</button>
) : (
<button
onClick={() => handleRegenerate(takesAsset.id)}
disabled={isRegenerating}
className="px-2 py-1 rounded-lg bg-blue-600/20 text-blue-300 hover:bg-blue-600/40 transition-colors text-[10px] font-medium flex items-center gap-1 disabled:opacity-50"
>
<RefreshCw className="h-3 w-3" />
New Take
</button>
)
)}
</div>
<div className="grid grid-cols-2 gap-2">
{takes.map((take, idx) => {
const isActive = (takesAsset.activeTakeIndex ?? 0) === idx
return (
<div
key={idx}
className={`relative group rounded-lg overflow-hidden border-2 transition-all cursor-pointer ${
isActive
? 'border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/20'
: 'border-zinc-800 hover:border-zinc-600'
}`}
onClick={() => {
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' ? (
<VideoThumbnailCard
videoUrl={pathToFileUrl(take.path)}
thumbnailUrl={take.smallThumbnailPath ? pathToFileUrl(take.smallThumbnailPath) : undefined}
/>
) : (
take.smallThumbnailPath ? (
<img src={pathToFileUrl(take.smallThumbnailPath)} alt="" className="w-full aspect-video object-cover" />
) : (
<div className="w-full aspect-video bg-zinc-800" />
)
)}
{isActive && <div className="absolute inset-0 bg-blue-600/15 pointer-events-none" />}
<div className="absolute bottom-1 left-1 flex items-center gap-1.5">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
isActive ? 'bg-blue-500 text-white' : 'bg-black/70 text-zinc-300'
}`}>
Take {idx + 1}
</span>
</div>
{isActive && (
<div className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded bg-blue-500 text-white text-[9px] font-semibold">
Active
</div>
)}
<div className="absolute bottom-1 right-1 px-1 py-0.5 rounded bg-black/70 text-[9px] text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity">
{new Date(take.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
{takes.length > 1 && (
<Tooltip content="Delete take" side="right">
<button
className="absolute top-1.5 right-1.5 p-1 rounded-md bg-black/70 text-zinc-400 hover:text-red-400 hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-all z-10"
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete take ${idx + 1}?`)) {
actions.deleteAssetTake(takesAsset.id, idx)
}
}}
>
<Trash2 className="h-3 w-3" />
</button>
</Tooltip>
)}
{isRegenerating && regeneratingAssetId === takesAsset.id && idx === takes.length - 1 && (
<div className="absolute inset-0 bg-blue-900/40 backdrop-blur-sm flex flex-col items-center justify-center z-20">
<Loader2 className="h-5 w-5 text-blue-300 animate-spin mb-1" />
<span className="text-[9px] text-blue-200 font-medium">{regenProgress}%</span>
<span className="text-[8px] text-blue-300/70 mb-1.5">{regenStatusMessage}</span>
{canCancelInFlight && (
<button
onClick={(e) => { e.stopPropagation(); handleCancelRegeneration() }}
className="px-2 py-0.5 rounded bg-zinc-800/80 border border-zinc-600/60 text-[9px] text-zinc-300 hover:text-red-400 hover:border-red-500/50 hover:bg-red-900/30 transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
})()
) : (
<div
className="flex-1 overflow-auto p-3 pt-0 relative select-none"
ref={assetGridRef}
onMouseDown={(e) => {
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 (
<div
className="absolute border border-blue-400 bg-blue-500/15 rounded-sm pointer-events-none z-30"
style={{ left, top, width, height }}
/>
)
})()}
{filteredAssets.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-zinc-500">No assets yet</p>
<p className="text-xs text-zinc-600 mt-1">Generate in Gen Space or import</p>
<button
onClick={() => fileInputRef.current?.click()}
className="mt-3 px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs hover:bg-zinc-700 transition-colors"
>
Import Media
</button>
</div>
) : assetViewMode === 'grid' ? (
<div className="grid grid-cols-2 gap-2">
{filteredAssets.map(asset => {
const cl = getColorLabel(asset.colorLabel)
return (
<div
key={asset.id}
data-asset-card
data-asset-id={asset.id}
className={`relative group cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
selectedAssetIds.has(asset.id)
? 'border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/20'
: 'border-zinc-800 hover:border-zinc-600'
}`}
draggable
onDragStart={(e) => {
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 && (
<>
<div className="absolute top-0 left-0 right-0 h-[3px] z-10" style={{ backgroundColor: cl.color }} />
<div className="absolute top-0 left-0 bottom-0 w-[3px] z-10" style={{ backgroundColor: cl.color }} />
</>
)}
{asset.type === 'video' ? (
<VideoThumbnailCard
videoUrl={pathToFileUrl(asset.path)}
thumbnailUrl={asset.smallThumbnailPath ? pathToFileUrl(asset.smallThumbnailPath) : undefined}
/>
) : asset.type === 'audio' ? (
<div className="w-full aspect-video bg-gradient-to-br from-emerald-900/60 to-zinc-900 flex flex-col items-center justify-center gap-1.5">
<Music className="h-6 w-6 text-emerald-400" />
<div className="flex items-center gap-0.5">
{[3, 5, 8, 6, 9, 4, 7, 5, 3, 6, 8, 4].map((h, i) => (
<div
key={i}
className="w-0.5 rounded-full bg-emerald-500/60"
style={{ height: `${h * 1.5}px` }}
/>
))}
</div>
<p className="text-[9px] text-emerald-300/70 truncate max-w-[90%] px-1">
{asset.path || 'Audio'}
</p>
</div>
) : asset.type === 'adjustment' ? (
<div className="w-full aspect-video bg-gradient-to-br from-blue-900/40 to-zinc-900 flex flex-col items-center justify-center gap-1.5 border border-dashed border-blue-500/30">
<Layers className="h-6 w-6 text-blue-400" />
<p className="text-[9px] text-blue-300/70 font-medium">Adjustment Layer</p>
</div>
) : (
asset.smallThumbnailPath ? (
<img src={pathToFileUrl(asset.smallThumbnailPath)} alt="" className="w-full aspect-video object-cover" />
) : (
<div className="w-full aspect-video bg-zinc-800" />
)
)}
{selectedAssetIds.has(asset.id) && <div className="absolute inset-0 bg-blue-600/25 pointer-events-none z-[1]" />}
{!selectedAssetIds.has(asset.id) && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none" />
)}
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all z-10">
{asset.generationParams && (
<Tooltip content="Regenerate" side="right">
<button
onClick={(e) => {
e.stopPropagation()
handleRegenerate(asset.id)
}}
disabled={isRegenerating}
className={`p-1 rounded bg-black/70 transition-colors ${
isRegenerating && regeneratingAssetId === asset.id
? 'text-blue-400 animate-spin'
: 'text-zinc-400 hover:text-blue-400 hover:bg-blue-900/50'
}`}
>
<RefreshCw className="h-3 w-3" />
</button>
</Tooltip>
)}
<Tooltip content="Delete asset" side="right">
<button
onClick={(e) => {
e.stopPropagation()
deleteAsset(asset.id)
}}
className="p-1 rounded bg-black/70 text-zinc-500 hover:text-red-400 hover:bg-red-900/50 transition-colors"
>
<X className="h-3 w-3" />
</button>
</Tooltip>
</div>
{isRegenerating && regeneratingAssetId === asset.id && (
<div className="absolute inset-0 bg-blue-900/40 backdrop-blur-sm flex flex-col items-center justify-center z-20">
<Loader2 className="h-5 w-5 text-blue-300 animate-spin mb-1" />
<span className="text-[9px] text-blue-200 font-medium">{regenProgress}%</span>
<span className="text-[8px] text-blue-300/70 mb-1.5">{regenStatusMessage}</span>
{canCancelInFlight && (
<button
onClick={(e) => { e.stopPropagation(); handleCancelRegeneration() }}
className="px-2 py-0.5 rounded bg-zinc-800/80 border border-zinc-600/60 text-[9px] text-zinc-300 hover:text-red-400 hover:border-red-500/50 hover:bg-red-900/30 transition-colors"
>
Cancel
</button>
)}
</div>
)}
{asset.takes && asset.takes.length > 1 && (
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 rounded bg-black/80 z-10">
<Tooltip content="Previous take" side="right">
<button
onClick={(e) => {
e.stopPropagation()
const idx = Math.max(0, (asset.activeTakeIndex ?? 0) - 1)
setAssetActiveTake(asset.id, idx)
}}
disabled={(asset.activeTakeIndex ?? 0) === 0}
className="p-0.5 text-blue-300 hover:text-white disabled:text-zinc-600 transition-colors"
>
<ChevronLeft className="h-3 w-3" />
</button>
</Tooltip>
<button
onClick={(e) => {
e.stopPropagation()
setTakesViewAssetId(asset.id)
setSelectedAssetIds(new Set())
}}
className="px-0.5 cursor-pointer hover:text-white transition-colors flex items-center gap-1"
title="View all takes"
>
<Layers className="h-2.5 w-2.5 text-blue-400" />
<span className="text-[9px] text-blue-300 font-medium">
{(asset.activeTakeIndex ?? 0) + 1}/{asset.takes.length}
</span>
</button>
<Tooltip content="Next take" side="right">
<button
onClick={(e) => {
e.stopPropagation()
if (asset.takes) {
const idx = Math.min(asset.takes.length - 1, (asset.activeTakeIndex ?? 0) + 1)
setAssetActiveTake(asset.id, idx)
}
}}
disabled={asset.takes && (asset.activeTakeIndex ?? 0) >= asset.takes.length - 1}
className="p-0.5 text-blue-300 hover:text-white disabled:text-zinc-600 transition-colors"
>
<ChevronRight className="h-3 w-3" />
</button>
</Tooltip>
</div>
)}
{asset.binId && binIdToName.get(asset.binId) && (
<div className="absolute top-1.5 left-8 flex items-center gap-0.5 px-1 py-0.5 rounded bg-black/70 text-[9px] text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<Folder className="h-2.5 w-2.5" />
{binIdToName.get(asset.binId)}
</div>
)}
<div className="absolute bottom-1 left-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-black/70 text-[10px] text-white">
{asset.type === 'video' ? <Video className="h-3 w-3" /> : asset.type === 'audio' ? <Music className="h-3 w-3" /> : asset.type === 'adjustment' ? <Layers className="h-3 w-3" /> : <Image className="h-3 w-3" />}
{asset.type === 'adjustment' ? 'Adj' : asset.duration ? `${asset.duration.toFixed(1)}s` : ''}
</div>
</div>
)
})}
</div>
) : (
<div className="flex flex-col">
<div className="flex items-center gap-1 px-2 py-1 border-b border-zinc-800 bg-zinc-900/80 sticky top-0 z-10">
<div className="w-2 flex-shrink-0" />
<div className="w-8 flex-shrink-0" />
{([
{ 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 }) => (
<button
key={col}
onClick={() => toggleSort(col)}
className={`${flex} flex items-center gap-0.5 text-[9px] font-semibold uppercase tracking-wider transition-colors cursor-pointer select-none ${
listSortCol === col ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
<span className="truncate">{label}</span>
{listSortCol === col ? (
listSortDir === 'asc' ? <ChevronUp className="h-2.5 w-2.5 flex-shrink-0" /> : <ChevronDown className="h-2.5 w-2.5 flex-shrink-0" />
) : (
<ArrowUpDown className="h-2.5 w-2.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
)}
</button>
))}
<div className="w-6 flex-shrink-0" />
</div>
{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 (
<div
key={asset.id}
data-asset-card
data-asset-id={asset.id}
className={`group flex items-center gap-1 px-2 py-1 cursor-pointer transition-all ${
selectedAssetIds.has(asset.id)
? 'bg-blue-600/20 ring-1 ring-blue-500/50'
: 'hover:bg-zinc-800/60'
}`}
draggable
onDragStart={(e) => {
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 ? (
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: cl.color }} />
) : (
<div className="w-2 flex-shrink-0" />
)}
<div className="w-8 h-6 flex-shrink-0 rounded overflow-hidden bg-zinc-800">
{asset.type === 'video' ? (
asset.smallThumbnailPath ? (
<img src={pathToFileUrl(asset.smallThumbnailPath)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-zinc-800" />
)
) : asset.type === 'audio' ? (
<div className="w-full h-full flex items-center justify-center bg-emerald-900/40"><Music className="h-2.5 w-2.5 text-emerald-400" /></div>
) : asset.type === 'adjustment' ? (
<div className="w-full h-full flex items-center justify-center bg-blue-900/30"><Layers className="h-2.5 w-2.5 text-blue-400" /></div>
) : (
asset.smallThumbnailPath ? (
<img src={pathToFileUrl(asset.smallThumbnailPath)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-zinc-800" />
)
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] text-zinc-200 truncate leading-tight">{name}</p>
{asset.takes && asset.takes.length > 1 && (
<span className="text-[8px] text-blue-400">{asset.takes.length} takes</span>
)}
</div>
<span className="w-14 flex-shrink-0 text-center text-[9px] text-zinc-500 uppercase font-medium">{asset.type}</span>
<span className="w-16 flex-shrink-0 text-right text-[9px] text-zinc-500 tabular-nums">
{asset.duration != null ? `${asset.duration.toFixed(1)}s` : '—'}
</span>
<span className="w-14 flex-shrink-0 text-right text-[9px] text-zinc-500">
{asset.resolution || '—'}
</span>
<span className="w-16 flex-shrink-0 text-right text-[9px] text-zinc-500">{dateStr}</span>
<div className="w-10 flex-shrink-0 flex items-center justify-center">
{cl ? (
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: cl.color }} title={cl.label} />
) : (
<span className="text-[9px] text-zinc-600">—</span>
)}
</div>
<Tooltip content="Delete asset" side="right">
<button
onClick={(e) => {
e.stopPropagation()
deleteAsset(asset.id)
}}
className="w-6 flex-shrink-0 flex items-center justify-center p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
>
<X className="h-3 w-3" />
</button>
</Tooltip>
</div>
)
})}
</div>
)}
</div>
)}
{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 (
<AssetContextMenu
asset={asset}
targetIds={targetIds}
assetContextMenu={assetContextMenu}
assetContextMenuRef={assetContextMenuRef}
addClipToTimeline={addClipToTimeline}
handleRegenerate={handleRegenerate}
handleCancelRegeneration={handleCancelRegeneration}
canCancelInFlight={canCancelInFlight}
setTakesViewAssetId={setTakesViewAssetId}
setSelectedAssetIds={setSelectedAssetIds}
setAssetContextMenu={setAssetContextMenu}
createAssetFromTake={createAssetFromTake}
openCreateBinEditor={openCreateBinEditor}
/>
)
})()}
{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 (
<TakeContextMenu
tcAsset={tcAsset}
take={take}
takeIndex={takeContextMenu.takeIndex}
takeContextMenu={takeContextMenu}
takeContextMenuRef={takeContextMenuRef}
addClipToTimeline={addClipToTimeline}
createAssetFromTake={createAssetFromTake}
setTakeContextMenu={setTakeContextMenu}
/>
)
})()}
{binContextMenu && (
<div
ref={binContextMenuRef}
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[160px] text-xs"
style={{ left: binContextMenu.x, top: binContextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
setCreatingBin(false)
setRenamingBinId(binContextMenu.binId)
setNewBinName(binIdToName.get(binContextMenu.binId) ?? '')
setSelectedBinId(binContextMenu.binId)
setBinContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Pencil className="h-3.5 w-3.5 text-zinc-500" />
<span>Rename Bin</span>
</button>
<button
onClick={() => {
actions.clearBin(binContextMenu.binId)
if (selectedBinId === binContextMenu.binId) setSelectedBinId(null)
setBinContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-zinc-700 flex items-center gap-3"
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Bin</span>
</button>
</div>
)}
</div>
)
})