Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Folder, MoreVertical, Trash2, Pencil } from 'lucide-react'
import { useProjects } from '../contexts/ProjectContext'
import { useView } from '../contexts/ViewContext'
import { LtxLogo } from '../components/LtxLogo'
import { Button } from '../components/ui/button'
import { pathToFileUrl } from '../lib/file-url'
import type { Project } from '../types/project-model'
import { useProjectReferencesMigration } from '../hooks/useProjectReferencesMigration'
function formatDate(timestamp: number): string {
const date = new Date(timestamp)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function ProjectCard({ project, onOpen, onDelete, onRename }: {
project: Project
onOpen: () => void
onDelete: () => void
onRename: () => void
}) {
const [showMenu, setShowMenu] = useState(false)
const [imgError, setImgError] = useState(false)
// Keep existing representative selection logic: prefer first image, else first asset.
const representativeAsset = project.assets.find(a => a.type === 'image') || project.assets[0] || null
const representativeUrl = representativeAsset?.path ? pathToFileUrl(representativeAsset.path) : null
const representativeBigThumbnailUrl = representativeAsset?.bigThumbnailPath
? pathToFileUrl(representativeAsset.bigThumbnailPath)
: null
return (
<div
className="group relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 hover:border-zinc-700 transition-colors cursor-pointer"
onClick={onOpen}
>
{/* Thumbnail */}
<div className="aspect-video bg-zinc-800 flex items-center justify-center relative overflow-hidden">
{representativeAsset && !imgError ? (
representativeAsset.type === 'video' ? (
representativeBigThumbnailUrl ? (
<img
src={representativeBigThumbnailUrl}
alt={project.name}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : representativeUrl ? (
<video
src={representativeUrl}
className="w-full h-full object-cover"
muted
preload="metadata"
onError={() => setImgError(true)}
/>
) : (
<Folder className="h-12 w-12 text-zinc-600" />
)
) : representativeUrl ? (
<img
src={representativeUrl}
alt={project.name}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<Folder className="h-12 w-12 text-zinc-600" />
)
) : (
<Folder className="h-12 w-12 text-zinc-600" />
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Info */}
<div className="p-3">
<h3 className="font-medium text-white truncate">{project.name}</h3>
<p className="text-xs text-zinc-500 mt-1">{formatDate(project.updatedAt)}</p>
</div>
{/* Menu button */}
<button
onClick={(e) => {
e.stopPropagation()
setShowMenu(!showMenu)
}}
className="absolute top-2 right-2 p-1.5 rounded bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70"
>
<MoreVertical className="h-4 w-4 text-white" />
</button>
{/* Dropdown menu */}
{showMenu && (
<div
className="absolute top-10 right-2 bg-zinc-800 rounded-lg shadow-lg border border-zinc-700 py-1 z-10 min-w-[120px]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => { onRename(); setShowMenu(false) }}
className="w-full px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
>
<Pencil className="h-4 w-4" />
Rename
</button>
<button
onClick={() => { onDelete(); setShowMenu(false) }}
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-zinc-700 flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
)}
</div>
)
}
export function Home() {
const { projectIds, getProject, createProject, deleteProject, renameProject } = useProjects()
const { openProject } = useView()
const { migrationStatus, migrateProjects } = useProjectReferencesMigration()
const [isCreating, setIsCreating] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const migrationStartedRef = useRef(false)
useEffect(() => {
if (migrationStatus.status !== 'needed' || migrationStartedRef.current) return
migrationStartedRef.current = true
void migrateProjects()
}, [migrateProjects, migrationStatus.status])
const projects = useMemo(() => (
projectIds
.map(projectId => getProject(projectId))
.filter((project): project is Project => project !== null)
), [getProject, projectIds])
const handleCreateProject = () => {
if (newProjectName.trim()) {
const project = createProject(newProjectName.trim())
setNewProjectName('')
setIsCreating(false)
openProject(project.id)
}
}
const handleRenameProject = (id: string, currentName: string) => {
setRenamingId(id)
setRenameValue(currentName)
}
const submitRename = () => {
if (renamingId && renameValue.trim()) {
renameProject(renamingId, renameValue.trim())
}
setRenamingId(null)
setRenameValue('')
}
if (migrationStatus.status === 'needed' || migrationStatus.status === 'inProgress') {
const progressPct = migrationStatus.status === 'inProgress'
? migrationStatus.ratio * 100
: 0
return (
<div className="h-screen bg-background flex items-center justify-center">
<div className="w-[360px]">
<p className="text-center text-sm text-zinc-300 mb-4">
Migrating project references...
</p>
<div className="h-2 w-full rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-150"
style={{ width: `${Math.max(0, Math.min(100, progressPct))}%` }}
/>
</div>
</div>
</div>
)
}
return (
<div className="h-screen bg-background flex">
{/* Sidebar */}
<aside className="w-64 border-r border-zinc-800 flex flex-col">
<div className="p-6">
<LtxLogo className="h-6 w-auto text-white" />
</div>
<nav className="flex-1 px-3">
<button className="w-full px-3 py-2 rounded-lg bg-zinc-800 text-white text-left text-sm font-medium flex items-center gap-2">
<Folder className="h-4 w-4" />
Home
</button>
{projects.length > 0 && (
<div className="mt-6">
<h4 className="px-3 text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
Recent Projects
</h4>
{projects.slice(0, 5).map(project => (
<button
key={project.id}
onClick={() => openProject(project.id)}
className="w-full px-3 py-2 rounded-lg text-zinc-400 hover:bg-zinc-800 hover:text-white text-left text-sm flex items-center gap-2 transition-colors truncate"
>
<Folder className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{project.name}</span>
</button>
))}
</div>
)}
</nav>
<div className="p-4 border-t border-zinc-800">
<button
onClick={() => setIsCreating(true)}
className="w-full px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
<Plus className="h-4 w-4" />
New Project
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
{/* Header Banner with video background */}
<div className="relative h-72 overflow-hidden">
<video
src="./hero-video.mp4"
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
{/* Dark overlay for text readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10" />
<div className="absolute bottom-6 left-8 z-10">
<h1 className="text-3xl font-bold text-white mb-2 drop-shadow-lg">LTX DeskWEB</h1>
<p className="text-zinc-200 drop-shadow-md">Create and manage your video projects</p>
</div>
</div>
{/* Projects Grid */}
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-white">Projects</h2>
</div>
{projects.length === 0 ? (
<div className="text-center py-16">
<Folder className="h-16 w-16 text-zinc-700 mx-auto mb-4" />
<h3 className="text-lg font-medium text-zinc-400 mb-2">No projects yet</h3>
<p className="text-zinc-500 mb-6">Create your first project to get started</p>
<Button
onClick={() => setIsCreating(true)}
className="bg-blue-600 hover:bg-blue-500"
>
<Plus className="h-4 w-4 mr-2" />
Create Project
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onOpen={() => openProject(project.id)}
onDelete={() => {
if (confirm(`Delete "${project.name}"?`)) {
deleteProject(project.id)
}
}}
onRename={() => handleRenameProject(project.id, project.name)}
/>
))}
</div>
)}
</div>
</main>
{/* Create Project Modal */}
{isCreating && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-zinc-900 rounded-xl p-6 w-full max-w-md border border-zinc-800">
<h2 className="text-xl font-semibold text-white mb-4">Create New Project</h2>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateProject()}
/>
<div className="flex gap-3 mt-6">
<Button
variant="outline"
onClick={() => { setIsCreating(false); setNewProjectName('') }}
className="flex-1 border-zinc-700"
>
Cancel
</Button>
<Button
onClick={handleCreateProject}
disabled={!newProjectName.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-500"
>
Create
</Button>
</div>
</div>
</div>
)}
{/* Rename Modal */}
{renamingId && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-zinc-900 rounded-xl p-6 w-full max-w-md border border-zinc-800">
<h2 className="text-xl font-semibold text-white mb-4">Rename Project</h2>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Project name"
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && submitRename()}
/>
<div className="flex gap-3 mt-6">
<Button
variant="outline"
onClick={() => { setRenamingId(null); setRenameValue('') }}
className="flex-1 border-zinc-700"
>
Cancel
</Button>
<Button
onClick={submitRename}
disabled={!renameValue.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-500"
>
Save
</Button>
</div>
</div>
</div>
)}
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeft, Sparkles, Film } from 'lucide-react'
import { useProjects } from '../contexts/ProjectContext'
import { useView } from '../contexts/ViewContext'
import { LtxLogo } from '../components/LtxLogo'
import { Button } from '../components/ui/button'
import { GenSpace } from './GenSpace'
import { VideoEditor } from './VideoEditor'
import type { ProjectTab } from '../types/project-model'
import {
hasVisualAssetMetadataForMigration,
runVisualAssetMetadataMigration,
} from '../lib/project-asset-metadata-migration'
export function Project() {
const {
activeProject,
currentTab,
setProject,
setCurrentTab,
updateAsset,
pendingRetakeUpdate,
setPendingRetakeUpdate,
pendingIcLoraUpdate,
setPendingIcLoraUpdate,
} = useProjects()
const { goHome } = useView()
const [assetMetadataMigrationProgress, setAssetMetadataMigrationProgress] = useState({ running: false, total: 0, completed: 0 })
const [upgradePassProjectId, setUpgradePassProjectId] = useState<string | null>(null)
const activeProjectId = activeProject?.id ?? null
const activeProjectAssets = activeProject?.assets ?? null
const needsAssetMetadataMigration = activeProjectAssets
? hasVisualAssetMetadataForMigration(activeProjectAssets)
: false
const handleSaveActiveProject = useCallback((project: typeof activeProject extends null ? never : NonNullable<typeof activeProject>) => {
if (!activeProjectId) return
setProject(activeProjectId, project)
}, [activeProjectId, setProject])
useEffect(() => {
if (!activeProjectId || !activeProjectAssets || !needsAssetMetadataMigration) return
let cancelled = false
const runAssetMetadataMigration = async () => {
for await (const event of runVisualAssetMetadataMigration(activeProjectAssets, window.electronAPI)) {
if (cancelled) return
if (event.kind === 'progress') {
setAssetMetadataMigrationProgress({ running: true, total: event.total, completed: event.completed })
continue
}
for (const update of event.updates) {
updateAsset(activeProjectId, update.assetId, update.updates)
}
setAssetMetadataMigrationProgress({ running: false, total: 0, completed: 0 })
setUpgradePassProjectId(activeProjectId)
}
}
void runAssetMetadataMigration()
return () => {
cancelled = true
}
}, [activeProjectAssets, activeProjectId, needsAssetMetadataMigration, updateAsset])
useEffect(() => {
if (currentTab !== 'video-editor') return
if (pendingRetakeUpdate) setPendingRetakeUpdate(null)
if (pendingIcLoraUpdate) setPendingIcLoraUpdate(null)
}, [
currentTab,
pendingRetakeUpdate,
setPendingRetakeUpdate,
pendingIcLoraUpdate,
setPendingIcLoraUpdate,
])
if (!activeProject) {
return (
<div className="h-screen bg-background flex items-center justify-center">
<div className="text-center">
<p className="text-zinc-400 mb-4">Project not found</p>
<Button onClick={goHome}>Go Home</Button>
</div>
</div>
)
}
const tabs: { id: ProjectTab; label: string; icon: React.ReactNode }[] = [
{ id: 'gen-space', label: 'Gen Space', icon: <Sparkles className="h-4 w-4" /> },
{ id: 'video-editor', label: 'Video Editor', icon: <Film className="h-4 w-4" /> },
]
const shouldShowAssetMetadataMigrationProgressScreen = assetMetadataMigrationProgress.running
|| (upgradePassProjectId !== activeProjectId && needsAssetMetadataMigration)
if (shouldShowAssetMetadataMigrationProgressScreen) {
const progressPct = assetMetadataMigrationProgress.total > 0
? (assetMetadataMigrationProgress.completed / assetMetadataMigrationProgress.total) * 100
: 0
return (
<div className="h-screen bg-background flex items-center justify-center">
<div className="w-[360px]">
<p className="text-center text-sm text-zinc-300 mb-4">
Preparing your project assets...
</p>
<div className="h-2 w-full rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-150"
style={{ width: `${Math.max(0, Math.min(100, progressPct))}%` }}
/>
</div>
</div>
</div>
)
}
return (
<div className="h-screen bg-background flex flex-col">
{/* Header */}
<header className="flex items-center px-4 py-3 border-b border-zinc-800">
<div className="flex-1 flex items-center gap-4">
{/* Back button and logo */}
<button
onClick={goHome}
className="p-2 rounded-lg hover:bg-zinc-800 transition-colors"
>
<ArrowLeft className="h-5 w-5 text-zinc-400" />
</button>
<LtxLogo className="h-5 w-auto text-white" />
{/* Project name */}
<span className="text-white font-medium">{activeProject.name}</span>
</div>
{/* Center - Tabs */}
<div className="flex items-center gap-1 bg-zinc-900 rounded-lg p-1">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setCurrentTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
currentTab === tab.id
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Right spacer - equal to left to keep tabs centered */}
<div className="flex-1" />
</header>
<main className="flex-1 overflow-hidden relative">
{currentTab === 'gen-space' ? (
<GenSpace />
) : (
<VideoEditor
key={activeProject.id}
currentProject={activeProject}
saveProject={handleSaveActiveProject}
pendingRetakeUpdate={pendingRetakeUpdate}
pendingIcLoraUpdate={pendingIcLoraUpdate}
/>
)}
</main>
</div>
)
}
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
import React, { type RefObject } from 'react'
import {
Plus, X, RefreshCw, ChevronLeft, ChevronRight, Layers, GitMerge,
FolderPlus, Folder, Trash2, FolderOpen,
} from 'lucide-react'
import type { Asset } from '../../types/project-model'
import { COLOR_LABELS } from './video-editor-utils'
import { equalAssetBins, selectAssetBins, selectAssets, selectRegenerationState } from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
export interface AssetContextMenuProps {
asset: Asset
targetIds: string[]
assetContextMenu: { assetId: string; x: number; y: number }
assetContextMenuRef: RefObject<HTMLDivElement>
addClipToTimeline: (asset: Asset, trackIndex?: number, startTime?: number) => void
handleRegenerate: (assetId: string) => void
handleCancelRegeneration: () => void
canCancelInFlight: boolean
setTakesViewAssetId: (assetId: string | null) => void
setSelectedAssetIds: React.Dispatch<React.SetStateAction<Set<string>>>
setAssetContextMenu: React.Dispatch<React.SetStateAction<{ assetId: string; x: number; y: number } | null>>
createAssetFromTake: (asset: Asset, take: NonNullable<Asset['takes']>[number]) => Asset
openCreateBinEditor: (assetIds: string[]) => void
}
export function AssetContextMenu({
asset,
targetIds,
assetContextMenu,
assetContextMenuRef,
addClipToTimeline,
handleRegenerate,
handleCancelRegeneration,
canCancelInFlight,
setTakesViewAssetId,
setSelectedAssetIds,
setAssetContextMenu,
createAssetFromTake,
openCreateBinEditor,
}: AssetContextMenuProps) {
const actions = useEditorActions()
const assets = useEditorStore(selectAssets)
const bins = useEditorStore(selectAssetBins, equalAssetBins)
const regenerationState = useEditorStore(selectRegenerationState)
const isRegenerating = regenerationState.regeneratingAssetId !== null || regenerationState.regeneratingClipId !== null
const regeneratingAssetId = regenerationState.regeneratingAssetId
const isMulti = targetIds.length > 1
const closeMenu = () => setAssetContextMenu(null)
const clearSelection = () => setSelectedAssetIds(new Set())
const setActiveTake = (assetId: string, takeIndex: number) => {
actions.setAssetActiveTake(assetId, takeIndex)
}
const setColor = (colorLabel?: string) => {
for (const id of targetIds) {
actions.setAssetColorLabel(id, colorLabel)
}
closeMenu()
}
const moveToBin = (binId?: string) => {
actions.assignAssetsToBin(targetIds, binId)
clearSelection()
closeMenu()
}
const deleteTargetAssets = () => {
actions.deleteAssets(targetIds)
clearSelection()
closeMenu()
}
return (
<div
ref={assetContextMenuRef}
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[180px] text-xs"
style={{ left: assetContextMenu.x, top: assetContextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
{isMulti && (
<div className="px-3 py-1 text-[10px] text-blue-400 font-medium">
{targetIds.length} assets selected
</div>
)}
{!isMulti && (
<button
onClick={() => {
addClipToTimeline(asset, 0)
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Plus className="h-3.5 w-3.5 text-zinc-500" />
<span>Add to Timeline</span>
</button>
)}
{!isMulti && asset.path && (
<button
onClick={() => {
window.electronAPI?.showItemInFolder({ filePath: asset.path! })
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<FolderOpen className="h-3.5 w-3.5 text-zinc-500" />
<span>Download file</span>
</button>
)}
{!isMulti && asset.generationParams && (
<>
{isRegenerating && regeneratingAssetId === asset.id && canCancelInFlight ? (
<button
onClick={() => {
handleCancelRegeneration()
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-zinc-700 flex items-center gap-3"
>
<X className="h-3.5 w-3.5" />
<span>Cancel Regeneration</span>
</button>
) : (
<button
onClick={() => {
handleRegenerate(asset.id)
closeMenu()
}}
disabled={isRegenerating}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3 disabled:opacity-50"
>
<RefreshCw className="h-3.5 w-3.5 text-zinc-500" />
<span>Regenerate</span>
</button>
)}
</>
)}
{!isMulti && asset.takes && asset.takes.length > 1 && (
<>
<div className="px-3 py-1.5 flex items-center gap-2">
<span className="text-[10px] text-zinc-500">Take:</span>
<button
onClick={(e) => {
e.stopPropagation()
const idx = Math.max(0, (asset.activeTakeIndex ?? 0) - 1)
setActiveTake(asset.id, idx)
}}
disabled={(asset.activeTakeIndex ?? 0) === 0}
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white disabled:text-zinc-600 disabled:hover:bg-transparent"
>
<ChevronLeft className="h-3 w-3" />
</button>
<span className="text-[10px] text-zinc-300 min-w-[28px] text-center">
{(asset.activeTakeIndex ?? 0) + 1}/{asset.takes.length}
</span>
<button
onClick={(e) => {
e.stopPropagation()
const idx = Math.min(asset.takes!.length - 1, (asset.activeTakeIndex ?? 0) + 1)
setActiveTake(asset.id, idx)
}}
disabled={(asset.activeTakeIndex ?? 0) >= asset.takes.length - 1}
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white disabled:text-zinc-600 disabled:hover:bg-transparent"
>
<ChevronRight className="h-3 w-3" />
</button>
</div>
<button
onClick={() => {
setTakesViewAssetId(asset.id)
clearSelection()
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Layers className="h-3.5 w-3.5 text-zinc-500" />
<span>View All Takes</span>
</button>
<button
onClick={() => {
if (!asset.takes) return
const splitAssets = asset.takes.slice(1).map(take => createAssetFromTake(asset, take))
const firstTake = asset.takes[0]
if (splitAssets.length > 0) actions.addAssetsToEditor(splitAssets)
actions.updateAsset(asset.id, {
takes: [firstTake],
activeTakeIndex: 0,
path: firstTake.path,
bigThumbnailPath: firstTake.bigThumbnailPath,
smallThumbnailPath: firstTake.smallThumbnailPath,
width: firstTake.width,
height: firstTake.height,
})
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<GitMerge className="h-3.5 w-3.5 text-zinc-500 rotate-180" />
<span>Ungroup Takes</span>
</button>
<button
onClick={() => {
const activeIdx = asset.activeTakeIndex ?? 0
if (confirm(`Delete take ${activeIdx + 1}?`)) {
actions.deleteAssetTake(asset.id, activeIdx)
}
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-red-900/30 flex items-center gap-3"
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Active Take</span>
</button>
</>
)}
<div className="h-px bg-zinc-700 my-1" />
<div className="px-3 py-1 text-[10px] text-zinc-500 font-semibold uppercase tracking-wider">Label</div>
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
<button
onClick={() => setColor(undefined)}
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${
!asset.colorLabel ? 'border-white scale-110' : 'border-zinc-600 hover:border-zinc-400'
}`}
title="No label"
>
<X className="h-2 w-2 text-zinc-400" />
</button>
{COLOR_LABELS.map(cl => (
<button
key={cl.id}
onClick={() => setColor(cl.id)}
className={`w-4 h-4 rounded-full transition-all ${
asset.colorLabel === cl.id ? 'ring-2 ring-white ring-offset-1 ring-offset-zinc-800 scale-110' : 'hover:scale-125'
}`}
style={{ backgroundColor: cl.color }}
title={cl.label}
/>
))}
</div>
<div className="h-px bg-zinc-700 my-1" />
<div className="px-3 py-1 text-[10px] text-zinc-500 font-semibold uppercase tracking-wider">Move to Bin</div>
<button
onClick={() => moveToBin(undefined)}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<X className="h-3.5 w-3.5 text-zinc-500" />
<span>Remove from Bin</span>
</button>
{bins.map(bin => (
<button
key={bin.id}
onClick={() => moveToBin(bin.id)}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Folder className="h-3.5 w-3.5 text-zinc-500" />
<span>{bin.name}</span>
</button>
))}
<button
onClick={() => {
openCreateBinEditor(targetIds)
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<FolderPlus className="h-3.5 w-3.5 text-zinc-500" />
<span>New Bin...</span>
</button>
{isMulti && (
<>
<div className="h-px bg-zinc-700 my-1" />
<button
onClick={() => {
const selectedAssets = assets.filter(a => targetIds.includes(a.id))
if (selectedAssets.length < 2) return
const primary = selectedAssets[0]
const newTakes = selectedAssets.map(a => ({
path: a.path,
bigThumbnailPath: a.bigThumbnailPath,
smallThumbnailPath: a.smallThumbnailPath,
width: a.width,
height: a.height,
createdAt: a.createdAt,
}))
actions.updateAsset(primary.id, {
takes: newTakes,
activeTakeIndex: 0,
path: newTakes[0].path,
bigThumbnailPath: newTakes[0].bigThumbnailPath,
smallThumbnailPath: newTakes[0].smallThumbnailPath,
width: newTakes[0].width,
height: newTakes[0].height,
})
actions.deleteAssets(selectedAssets.slice(1).map(a => a.id))
clearSelection()
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-blue-300 hover:bg-zinc-700 flex items-center gap-3"
>
<GitMerge className="h-3.5 w-3.5" />
<span>Group as Takes</span>
</button>
<button
onClick={() => {
clearSelection()
closeMenu()
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<X className="h-3.5 w-3.5 text-zinc-500" />
<span>Clear Selection</span>
</button>
</>
)}
<div className="h-px bg-zinc-700 my-1" />
<button
onClick={deleteTargetAssets}
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>{isMulti ? `Delete ${targetIds.length} Assets` : 'Delete Asset'}</span>
</button>
</div>
)
}
+763
View File
@@ -0,0 +1,763 @@
import React from 'react'
import {
Clipboard, Copy, Scissors, Trash2, Layers, Type, X, RefreshCw,
ZoomIn, Film, Eye, FolderOpen, RotateCcw, Volume2, VolumeX,
FlipHorizontal2, FlipVertical2, Link2, Unlink2,
ChevronLeft, ChevronRight, Sparkles,
Video, Camera,
} from 'lucide-react'
import type { Asset, TimelineClip, Track, TextOverlayStyle } from '../../types/project-model'
import { TEXT_PRESETS } from '../../types/project'
import { COLOR_LABELS } from './video-editor-utils'
export type ClipContextMenuState =
| { kind: 'background'; x: number; y: number }
| { kind: 'clip'; clipId: string; x: number; y: number }
export interface ClipContextMenuProps {
clipContextMenu: ClipContextMenuState
contextClip: TimelineClip | null
clipContextMenuRef: React.RefObject<HTMLDivElement>
clips: TimelineClip[]
tracks: Track[]
selectedClipIds: Set<string>
setSelectedClipIds: React.Dispatch<React.SetStateAction<Set<string>>>
currentTime: number
hasClipboard: boolean
isRegenerating: boolean
currentProjectId: string | null
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
handleCopy: () => void
handleCut: () => void
handlePaste: () => void
setClipContextMenu: React.Dispatch<React.SetStateAction<ClipContextMenuState | null>>
addTextClip: (style?: Partial<TextOverlayStyle>, startTime?: number) => void
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
handleRegenerate: (assetId: string, clipId: string) => void
handleCancelRegeneration: () => void
canCancelInFlight: boolean
handleClipTakeChange: (clipId: string, direction: 'prev' | 'next') => void
handleDeleteTake: (clipId: string) => void
duplicateClip: (clipId: string) => void
splitClipAtPlayhead: (clipId: string, atTime?: number, batchClipIds?: string[]) => void
removeClip: (clipId: string) => void
updateClip: (clipId: string, updates: Partial<TimelineClip>) => void
getLiveAsset: (clip: TimelineClip) => Asset | null | undefined
getMaxClipDuration: (clip: TimelineClip) => number
onRevealAsset: (assetId: string) => void
onCreateVideoFromImage: (clip: TimelineClip) => void
onRetakeClip: (clip: TimelineClip) => void
onICLoraClip: (clip: TimelineClip) => void
canUseIcLora: boolean
canUseRetake: boolean
onCaptureFrameForVideo: (clip: TimelineClip) => void
onCreateVideoFromAudio: (clip: TimelineClip) => void
}
// Reusable menu item component
function MenuItem({ icon: Icon, iconClass, label, shortcut, badge, badgeClass, disabled, danger, title, onClick }: {
icon: any; iconClass?: string; label: string; shortcut?: string; badge?: string; badgeClass?: string
disabled?: boolean; danger?: boolean; title?: string; onClick: () => void
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`w-full text-left px-3 py-1.5 flex items-center gap-3 transition-colors ${
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-zinc-700'
} ${danger ? 'text-red-400' : 'text-zinc-300'}`}
>
<Icon className={`h-3.5 w-3.5 flex-shrink-0 ${iconClass || (danger ? '' : 'text-zinc-500')}`} />
<span className="flex-1 truncate">{label}</span>
{badge && <span className={`text-[10px] font-medium flex-shrink-0 ${badgeClass || 'text-zinc-500'}`}>{badge}</span>}
{shortcut && <span className="text-zinc-600 text-[10px] flex-shrink-0">{shortcut}</span>}
</button>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pt-1.5 pb-0.5 text-[9px] text-zinc-500 font-semibold uppercase tracking-widest select-none">
{children}
</div>
)
}
function Divider() {
return <div className="h-px bg-zinc-700 my-1" />
}
export function ClipContextMenu({
clipContextMenu,
contextClip,
clipContextMenuRef,
clips,
selectedClipIds,
setSelectedClipIds,
currentTime,
hasClipboard,
isRegenerating,
currentProjectId,
updateAsset,
handleCopy,
handleCut,
handlePaste,
setClipContextMenu,
addTextClip,
setClips,
handleRegenerate,
handleCancelRegeneration,
canCancelInFlight,
handleClipTakeChange,
handleDeleteTake,
duplicateClip,
splitClipAtPlayhead,
removeClip,
updateClip,
getLiveAsset,
getMaxClipDuration,
onRevealAsset,
onCreateVideoFromImage,
onRetakeClip,
onICLoraClip,
canUseIcLora,
canUseRetake,
onCaptureFrameForVideo,
onCreateVideoFromAudio,
}: ClipContextMenuProps) {
const close = () => setClipContextMenu(null)
const isBackground = clipContextMenu.kind === 'background'
// Check if all selected clips are in the same linked group — if so, treat as single selection
const multiSelected = (() => {
if (selectedClipIds.size <= 1) return false
if (!contextClip) return clipContextMenu.kind === 'background'
// Expand the linked group of the context clip
const linkedGroup = new Set([contextClip.id])
const queue = [contextClip.id]
while (queue.length > 0) {
const id = queue.pop()!
const c = clips.find(cl => cl.id === id)
if (c?.linkedClipIds) {
for (const lid of c.linkedClipIds) {
if (!linkedGroup.has(lid)) { linkedGroup.add(lid); queue.push(lid) }
}
}
}
// If every selected clip is in this linked group, it's a single logical selection
for (const sid of selectedClipIds) {
if (!linkedGroup.has(sid)) return true
}
return false
})()
return (
<div
ref={clipContextMenuRef}
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[220px] max-w-[280px] text-xs"
style={{ left: clipContextMenu.x, top: clipContextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
{/* ════════════════════════════════════════════════
BACKGROUND CONTEXT MENU (empty area right-click)
════════════════════════════════════════════════ */}
{isBackground ? (
<>
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard}
onClick={() => { handlePaste(); close() }} />
<Divider />
<MenuItem icon={Type} iconClass="text-cyan-400" label="Add Text"
onClick={() => { addTextClip(undefined, currentTime); close() }} />
{TEXT_PRESETS.slice(0, 4).map(preset => (
<button
key={preset.id}
onClick={() => { addTextClip(preset.style, currentTime); close() }}
className="w-full text-left px-3 py-1.5 text-zinc-400 hover:bg-zinc-700 flex items-center gap-3 pl-9"
>
<span className="text-[10px] text-cyan-500/70 flex-shrink-0">T</span>
<span className="flex-1 truncate">{preset.name}</span>
</button>
))}
<Divider />
<MenuItem icon={Layers} label="Select All" shortcut="Ctrl+A"
onClick={() => { setSelectedClipIds(new Set(clips.map(c => c.id))); close() }} />
</>
) : multiSelected ? (
/* ════════════════════════════════════════════════
MULTI-CLIP CONTEXT MENU
════════════════════════════════════════════════ */
<MultiClipMenu
clips={clips}
selectedClipIds={selectedClipIds} setSelectedClipIds={setSelectedClipIds}
hasClipboard={hasClipboard}
currentProjectId={currentProjectId}
updateAsset={updateAsset}
handleCopy={handleCopy} handleCut={handleCut} handlePaste={handlePaste}
setClips={setClips}
getMaxClipDuration={getMaxClipDuration}
close={close}
/>
) : contextClip ? (
/* ════════════════════════════════════════════════
SINGLE CLIP CONTEXT MENU
(For linked groups treated as single, prefer the video/image clip)
════════════════════════════════════════════════ */
<SingleClipMenu
contextClip={(() => {
if (contextClip.type !== 'audio') return contextClip
if (contextClip.linkedClipIds?.length) {
const primary = clips.find(c => contextClip.linkedClipIds!.includes(c.id) && (c.type === 'video' || c.type === 'image'))
if (primary) return primary
}
return contextClip
})()}
clips={clips}
hasClipboard={hasClipboard}
isRegenerating={isRegenerating}
currentProjectId={currentProjectId}
updateAsset={updateAsset}
handleCopy={handleCopy} handleCut={handleCut} handlePaste={handlePaste}
setClips={setClips}
handleRegenerate={handleRegenerate}
handleCancelRegeneration={handleCancelRegeneration}
canCancelInFlight={canCancelInFlight}
handleClipTakeChange={handleClipTakeChange}
handleDeleteTake={handleDeleteTake}
duplicateClip={duplicateClip}
splitClipAtPlayhead={splitClipAtPlayhead}
removeClip={removeClip}
updateClip={updateClip}
getLiveAsset={getLiveAsset}
getMaxClipDuration={getMaxClipDuration}
onRevealAsset={onRevealAsset}
onCreateVideoFromImage={onCreateVideoFromImage}
onRetakeClip={onRetakeClip}
onICLoraClip={onICLoraClip}
canUseIcLora={canUseIcLora}
canUseRetake={canUseRetake}
onCaptureFrameForVideo={onCaptureFrameForVideo}
onCreateVideoFromAudio={onCreateVideoFromAudio}
close={close}
/>
) : null}
</div>
)
}
/* ──────────────────────────────────────────────
Single Clip Menu
Layout:
1. Clipboard (Cut / Copy / Paste)
2. Edit (Duplicate / Split)
3. Properties (Speed / Reverse / Mute)
4. Transform (Flip H / Flip V)
5. Structure (Link / Move Track)
6. AI Tools (Regenerate / Upscale / I2V / Retake / IC-LoRA)
7. Navigation (Reveal in Assets / Explorer)
8. Delete (always last, red)
────────────────────────────────────────────── */
function SingleClipMenu({
contextClip, clips, hasClipboard,
isRegenerating,
currentProjectId, updateAsset,
handleCopy, handleCut, handlePaste, setClips,
handleRegenerate, handleCancelRegeneration, canCancelInFlight,
handleClipTakeChange, handleDeleteTake,
duplicateClip, splitClipAtPlayhead, removeClip, updateClip,
getLiveAsset, getMaxClipDuration,
onRevealAsset,
onCreateVideoFromImage, onRetakeClip, onICLoraClip, canUseIcLora, canUseRetake,
onCaptureFrameForVideo,
onCreateVideoFromAudio,
close,
}: {
contextClip: TimelineClip
clips: TimelineClip[]
hasClipboard: boolean
isRegenerating: boolean
currentProjectId: string | null
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
handleCopy: () => void; handleCut: () => void; handlePaste: () => void
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
handleRegenerate: (assetId: string, clipId: string) => void
handleCancelRegeneration: () => void
canCancelInFlight: boolean
handleClipTakeChange: (clipId: string, direction: 'prev' | 'next') => void
handleDeleteTake: (clipId: string) => void
duplicateClip: (clipId: string) => void
splitClipAtPlayhead: (clipId: string, atTime?: number, batchClipIds?: string[]) => void
removeClip: (clipId: string) => void
updateClip: (clipId: string, updates: Partial<TimelineClip>) => void
getLiveAsset: (clip: TimelineClip) => Asset | null | undefined
getMaxClipDuration: (clip: TimelineClip) => number
onRevealAsset: (assetId: string) => void
onCreateVideoFromImage: (clip: TimelineClip) => void
onRetakeClip: (clip: TimelineClip) => void
onICLoraClip: (clip: TimelineClip) => void
canUseIcLora: boolean
canUseRetake: boolean
onCaptureFrameForVideo: (clip: TimelineClip) => void
onCreateVideoFromAudio: (clip: TimelineClip) => void
close: () => void
}) {
const liveAsset = getLiveAsset(contextClip)
const isAdjustment = contextClip.type === 'adjustment'
const isVideo = contextClip.type === 'video'
const isImage = contextClip.type === 'image'
const hasAI = liveAsset && !isAdjustment
return (
<>
{/* ── 1. Clipboard ── */}
<MenuItem icon={Scissors} label="Cut" shortcut="Ctrl+X" onClick={() => { handleCut(); close() }} />
<MenuItem icon={Copy} label="Copy" shortcut="Ctrl+C" onClick={() => { handleCopy(); close() }} />
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard} onClick={() => { handlePaste(); close() }} />
<Divider />
{/* ── 2. Edit ── */}
<MenuItem icon={Copy} label="Duplicate" onClick={() => { duplicateClip(contextClip.id); close() }} />
<MenuItem icon={Scissors} label="Split at Playhead" shortcut="B" onClick={() => { splitClipAtPlayhead(contextClip.id); close() }} />
<Divider />
{/* ── 3. Properties ── */}
<SectionLabel>Speed</SectionLabel>
<div className="flex items-center gap-1 px-3 py-1">
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
<button
key={speed}
onClick={() => {
const oldSpeed = contextClip.speed
let newDuration = contextClip.duration * (oldSpeed / speed)
const maxDur = getMaxClipDuration({ ...contextClip, speed })
newDuration = Math.min(newDuration, maxDur)
newDuration = Math.max(0.5, newDuration)
updateClip(contextClip.id, { speed, duration: newDuration })
close()
}}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
contextClip.speed === speed
? 'bg-blue-600 text-white'
: 'bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white'
}`}
>
{speed}x
</button>
))}
</div>
<MenuItem icon={RotateCcw} label="Reverse"
badge={contextClip.reversed ? 'ON' : undefined} badgeClass="text-blue-400"
onClick={() => { updateClip(contextClip.id, { reversed: !contextClip.reversed }); close() }} />
<MenuItem
icon={contextClip.muted ? VolumeX : Volume2}
label={contextClip.muted ? 'Unmute' : 'Mute'}
badge={contextClip.muted ? 'MUTED' : undefined} badgeClass="text-red-400"
onClick={() => { updateClip(contextClip.id, { muted: !contextClip.muted }); close() }} />
<Divider />
{/* ── 4. Transform ── */}
<MenuItem icon={FlipHorizontal2} label="Flip Horizontal"
badge={contextClip.flipH ? 'ON' : undefined} badgeClass="text-cyan-400"
onClick={() => { updateClip(contextClip.id, { flipH: !contextClip.flipH }); close() }} />
<MenuItem icon={FlipVertical2} label="Flip Vertical"
badge={contextClip.flipV ? 'ON' : undefined} badgeClass="text-cyan-400"
onClick={() => { updateClip(contextClip.id, { flipV: !contextClip.flipV }); close() }} />
<Divider />
{/* ── 5. Structure (Link / Track) ── */}
{contextClip.linkedClipIds?.length ? (
<MenuItem icon={Unlink2} label="Unlink Audio" onClick={() => {
const allLinked = new Set(contextClip.linkedClipIds!)
setClips(prev => prev.map(c => {
if (c.id === contextClip.id) return { ...c, linkedClipIds: undefined }
if (allLinked.has(c.id)) {
const remaining = (c.linkedClipIds || []).filter(lid => lid !== contextClip.id)
return { ...c, linkedClipIds: remaining.length ? remaining : undefined }
}
return c
}))
close()
}} />
) : null}
{!contextClip.linkedClipIds?.length && (contextClip.type === 'video' || contextClip.type === 'audio') && (() => {
const oppositeType = contextClip.type === 'video' ? 'audio' : 'video'
const candidates = clips.filter(c =>
c.type === oppositeType && !c.linkedClipIds?.length &&
c.assetId === contextClip.assetId && Math.abs(c.startTime - contextClip.startTime) < 0.05
)
if (!candidates.length) return null
return (
<MenuItem icon={Link2} label="Link Audio" onClick={() => {
const candidateIds = candidates.map(c => c.id)
setClips(prev => prev.map(c => {
if (c.id === contextClip.id) return { ...c, linkedClipIds: candidateIds }
if (candidateIds.includes(c.id)) return { ...c, linkedClipIds: [contextClip.id] }
return c
}))
close()
}} />
)
})()}
{/* ── 6. Color Label ── */}
<Divider />
<SectionLabel>Label</SectionLabel>
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
<button
onClick={() => {
if (contextClip.assetId) {
// Update ALL clips that share this asset
setClips(prev => prev.map(c => c.assetId === contextClip.assetId ? { ...c, colorLabel: undefined } : c))
if (currentProjectId) updateAsset(currentProjectId, contextClip.assetId, { colorLabel: undefined })
} else {
updateClip(contextClip.id, { colorLabel: undefined })
}
close()
}}
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${
!contextClip.colorLabel ? 'border-white scale-110' : 'border-zinc-600 hover:border-zinc-400'
}`}
title="No label"
>
<X className="h-2 w-2 text-zinc-400" />
</button>
{COLOR_LABELS.map(cl => (
<button
key={cl.id}
onClick={() => {
if (contextClip.assetId) {
// Update ALL clips that share this asset
setClips(prev => prev.map(c => c.assetId === contextClip.assetId ? { ...c, colorLabel: cl.id } : c))
if (currentProjectId) updateAsset(currentProjectId, contextClip.assetId, { colorLabel: cl.id })
} else {
updateClip(contextClip.id, { colorLabel: cl.id })
}
close()
}}
className={`w-4 h-4 rounded-full transition-all ${
contextClip.colorLabel === cl.id ? 'ring-2 ring-white ring-offset-1 ring-offset-zinc-800 scale-110' : 'hover:scale-125'
}`}
style={{ backgroundColor: cl.color }}
title={cl.label}
/>
))}
</div>
{/* ── 7. AI Tools ── */}
{hasAI && (
<>
<Divider />
<SectionLabel>AI Tools</SectionLabel>
{contextClip.isRegenerating ? (
canCancelInFlight ? (
<MenuItem icon={X} iconClass="text-red-400" label="Cancel Regeneration" onClick={() => { handleCancelRegeneration(); close() }} />
) : null
) : (
<MenuItem icon={RefreshCw} iconClass="text-blue-400" label="Regenerate Shot"
disabled={isRegenerating} onClick={() => { handleRegenerate(contextClip.assetId!, contextClip.id); close() }} />
)}
{/* Take navigation */}
{liveAsset!.takes && liveAsset!.takes.length > 1 && (
<div className="px-3 py-1 flex items-center gap-2">
<span className="text-[10px] text-zinc-500 flex-shrink-0">Take:</span>
<button onClick={(e) => { e.stopPropagation(); handleClipTakeChange(contextClip.id, 'prev') }}
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white">
<ChevronLeft className="h-3 w-3" />
</button>
<span className="text-[10px] text-zinc-300 min-w-[28px] text-center tabular-nums">
{(contextClip.takeIndex ?? (liveAsset!.activeTakeIndex ?? liveAsset!.takes!.length - 1)) + 1}/{liveAsset!.takes!.length}
</span>
<button onClick={(e) => { e.stopPropagation(); handleClipTakeChange(contextClip.id, 'next') }}
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white">
<ChevronRight className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete take ${(contextClip.takeIndex ?? (liveAsset!.activeTakeIndex ?? liveAsset!.takes!.length - 1)) + 1}?`)) {
handleDeleteTake(contextClip.id)
}
}}
className="p-0.5 rounded hover:bg-red-900/50 text-zinc-500 hover:text-red-400 ml-auto"
title="Delete this take"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
{isVideo && contextClip.assetId && (
<MenuItem icon={ZoomIn} iconClass="text-zinc-500" label="Upscale (2x)"
disabled={true} title="Coming Soon!" onClick={() => {}} />
)}
{isImage && (
<MenuItem icon={Film} iconClass="text-blue-400" label="Image to Video (I2V)"
onClick={() => { onCreateVideoFromImage(contextClip); close() }} />
)}
{isVideo && contextClip.assetId && (
<>
{canUseRetake && (
<MenuItem icon={Film} iconClass="text-blue-400" label="Retake Section"
onClick={() => { onRetakeClip(contextClip); close() }} />
)}
{canUseIcLora && (
<MenuItem icon={Sparkles} iconClass="text-amber-400" label="IC-LoRA / Style Transfer"
onClick={() => { onICLoraClip(contextClip); close() }} />
)}
</>
)}
{contextClip.type === 'audio' && !contextClip.linkedClipIds?.length && (
<MenuItem
icon={Film}
iconClass="text-emerald-400"
label="Create Video (A2V)"
onClick={() => { onCreateVideoFromAudio(contextClip); close() }}
/>
)}
{(isVideo || isImage) && (
<div className="relative group/capture">
<button
className="w-full text-left px-3 py-1.5 flex items-center gap-3 transition-colors hover:bg-zinc-700 text-zinc-300"
>
<Camera className="h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<span className="flex-1 truncate">Use Frame As...</span>
<ChevronRight className="h-3 w-3 text-zinc-500" />
</button>
<div className="absolute left-full top-0 ml-0.5 min-w-[200px] bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-[70] hidden group-hover/capture:block">
<MenuItem icon={Video} iconClass="text-blue-400" label="Generate Video in Gen Space"
onClick={() => { onCaptureFrameForVideo(contextClip); close() }} />
{isImage && (
<MenuItem icon={Film} iconClass="text-blue-400" label="Image to Video (I2V)"
onClick={() => { onCreateVideoFromImage(contextClip); close() }} />
)}
</div>
</div>
)}
</>
)}
{/* ── 7. Navigation ── */}
{(contextClip.assetId || getLiveAsset(contextClip)?.path) && (
<>
<Divider />
{contextClip.assetId && (
<MenuItem icon={Eye} label="Reveal in Assets" onClick={() => {
onRevealAsset(contextClip.assetId!)
close()
}} />
)}
{(() => {
if (!liveAsset) return null
let filePath = liveAsset.path
if (liveAsset.takes && liveAsset.takes.length > 0) {
const takeIdx = contextClip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)
filePath = liveAsset.takes[Math.max(0, Math.min(takeIdx, liveAsset.takes.length - 1))].path
}
if (!filePath) return null
const label = window.electronAPI?.platform === 'darwin' ? 'Reveal in Finder'
: window.electronAPI?.platform === 'linux' ? 'Show in Files'
: 'Download file'
return <MenuItem icon={FolderOpen} label={label} onClick={() => { window.electronAPI?.showItemInFolder({ filePath }); close() }} />
})()}
</>
)}
{/* ── 8. Delete (always last, always red) ── */}
<Divider />
<MenuItem icon={Trash2} label="Delete" shortcut="Del" danger onClick={() => { removeClip(contextClip.id); close() }} />
</>
)
}
/* ──────────────────────────────────────────────
Multi-Clip Menu
Layout:
1. Header (N clips selected)
2. Clipboard (Cut / Copy / Paste)
3. Properties (Speed / Mute / Reverse)
4. Transform (Flip H / Flip V)
5. Structure (Link / Unlink / Move Track)
6. Delete (always last, red)
────────────────────────────────────────────── */
function MultiClipMenu({
clips, selectedClipIds, setSelectedClipIds, hasClipboard,
currentProjectId, updateAsset,
handleCopy, handleCut, handlePaste, setClips, getMaxClipDuration, close,
}: {
clips: TimelineClip[]
selectedClipIds: Set<string>; setSelectedClipIds: React.Dispatch<React.SetStateAction<Set<string>>>
hasClipboard: boolean
currentProjectId: string | null
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
handleCopy: () => void; handleCut: () => void; handlePaste: () => void
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
getMaxClipDuration: (clip: TimelineClip) => number
close: () => void
}) {
const n = selectedClipIds.size
const selectedClips = clips.filter(c => selectedClipIds.has(c.id))
const allMuted = selectedClips.every(c => c.muted)
const allReversed = selectedClips.every(c => c.reversed)
const allFlipH = selectedClips.every(c => c.flipH)
const allFlipV = selectedClips.every(c => c.flipV)
const batchUpdate = (updates: Partial<TimelineClip>) => {
setClips(prev => prev.map(c => selectedClipIds.has(c.id) ? { ...c, ...updates } : c))
close()
}
const anyLinked = selectedClips.some(c => c.linkedClipIds?.length)
const hasVideoAndAudio = selectedClips.some(c => c.type === 'video') && selectedClips.some(c => c.type === 'audio')
const allFullyLinked = selectedClips.every(c => {
const others = [...selectedClipIds].filter(id => id !== c.id)
return others.every(oid => c.linkedClipIds?.includes(oid))
})
return (
<>
{/* ── Header ── */}
<SectionLabel>{n} Clips Selected</SectionLabel>
{/* ── 1. Clipboard ── */}
<MenuItem icon={Scissors} label={`Cut ${n} Clips`} shortcut="Ctrl+X" onClick={() => { handleCut(); close() }} />
<MenuItem icon={Copy} label={`Copy ${n} Clips`} shortcut="Ctrl+C" onClick={() => { handleCopy(); close() }} />
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard} onClick={() => { handlePaste(); close() }} />
<Divider />
{/* ── 2. Properties ── */}
<SectionLabel>Speed</SectionLabel>
<div className="flex items-center gap-1 px-3 py-1">
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
<button
key={speed}
onClick={() => {
setClips(prev => prev.map(c => {
if (!selectedClipIds.has(c.id)) return c
const oldSpeed = c.speed
let newDuration = c.duration * (oldSpeed / speed)
const maxDur = getMaxClipDuration({ ...c, speed })
newDuration = Math.min(newDuration, maxDur)
newDuration = Math.max(0.5, newDuration)
return { ...c, speed, duration: newDuration }
}))
close()
}}
className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white"
>
{speed}x
</button>
))}
</div>
<MenuItem icon={allMuted ? VolumeX : Volume2} label={allMuted ? 'Unmute All' : 'Mute All'}
badge={allMuted ? 'ALL MUTED' : undefined} badgeClass="text-red-400"
onClick={() => batchUpdate({ muted: !allMuted })} />
<MenuItem icon={RotateCcw} label={allReversed ? 'Un-reverse All' : 'Reverse All'}
badge={allReversed ? 'ALL ON' : undefined} badgeClass="text-blue-400"
onClick={() => batchUpdate({ reversed: !allReversed })} />
<Divider />
{/* ── 3. Transform ── */}
<MenuItem icon={FlipHorizontal2} label={allFlipH ? 'Un-flip All Horizontal' : 'Flip All Horizontal'}
badge={allFlipH ? 'ALL ON' : undefined} badgeClass="text-cyan-400"
onClick={() => batchUpdate({ flipH: !allFlipH })} />
<MenuItem icon={FlipVertical2} label={allFlipV ? 'Un-flip All Vertical' : 'Flip All Vertical'}
badge={allFlipV ? 'ALL ON' : undefined} badgeClass="text-cyan-400"
onClick={() => batchUpdate({ flipV: !allFlipV })} />
<Divider />
{/* ── 4. Structure ── */}
{anyLinked && (
<MenuItem icon={Unlink2} label="Unlink" onClick={() => {
const selIds = new Set(selectedClipIds)
setClips(prev => prev.map(c => {
if (!selIds.has(c.id)) return c
const remaining = (c.linkedClipIds || []).filter(lid => !selIds.has(lid))
return { ...c, linkedClipIds: remaining.length ? remaining : undefined }
}))
close()
}} />
)}
{hasVideoAndAudio && !allFullyLinked && (
<MenuItem icon={Link2} label="Link" onClick={() => {
const selIds = [...selectedClipIds]
setClips(prev => prev.map(c => {
if (!selectedClipIds.has(c.id)) return c
const otherIds = selIds.filter(id => id !== c.id)
const existingLinks = new Set(c.linkedClipIds || [])
otherIds.forEach(id => existingLinks.add(id))
return { ...c, linkedClipIds: [...existingLinks] }
}))
close()
}} />
)}
{/* ── 5. Color Label ── */}
<Divider />
<SectionLabel>Label</SectionLabel>
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
<button
onClick={() => {
const affectedAssetIds = new Set(selectedClips.map(c => c.assetId).filter(Boolean) as string[])
// Update selected clips AND all other clips sharing the same assets
setClips(prev => prev.map(c => {
if (selectedClipIds.has(c.id) || (c.assetId && affectedAssetIds.has(c.assetId))) {
return { ...c, colorLabel: undefined }
}
return c
}))
if (currentProjectId) {
affectedAssetIds.forEach(id => updateAsset(currentProjectId!, id, { colorLabel: undefined }))
}
close()
}}
className="w-4 h-4 rounded-full border-2 border-zinc-600 hover:border-zinc-400 flex items-center justify-center transition-all"
title="No label"
>
<X className="h-2 w-2 text-zinc-400" />
</button>
{COLOR_LABELS.map(cl => (
<button
key={cl.id}
onClick={() => {
const affectedAssetIds = new Set(selectedClips.map(c => c.assetId).filter(Boolean) as string[])
// Update selected clips AND all other clips sharing the same assets
setClips(prev => prev.map(c => {
if (selectedClipIds.has(c.id) || (c.assetId && affectedAssetIds.has(c.assetId))) {
return { ...c, colorLabel: cl.id }
}
return c
}))
if (currentProjectId) {
affectedAssetIds.forEach(id => updateAsset(currentProjectId!, id, { colorLabel: cl.id }))
}
close()
}}
className="w-4 h-4 rounded-full hover:scale-125 transition-all"
style={{ backgroundColor: cl.color }}
title={cl.label}
/>
))}
</div>
{/* ── 6. Delete ── */}
<Divider />
<MenuItem icon={Trash2} label={`Delete ${n} Clips`} shortcut="Del" danger onClick={() => {
setClips(prev => prev.filter(c => !selectedClipIds.has(c.id)).map(c => {
if (!c.linkedClipIds) return c
const remaining = c.linkedClipIds.filter(lid => !selectedClipIds.has(lid))
return { ...c, linkedClipIds: remaining.length ? remaining : undefined }
}))
setSelectedClipIds(new Set())
close()
}} />
</>
)
}
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
import { useState } from 'react'
import { Sparkles, X, Search } from 'lucide-react'
import { EFFECT_DEFINITIONS } from '../../types/project'
import type { EffectType, TimelineClip } from '../../types/project-model'
interface EffectsBrowserProps {
onClose: () => void
selectedClip: TimelineClip | null
addEffectToClip: (clipId: string, effectType: EffectType) => void
}
export function EffectsBrowser({ onClose, selectedClip, addEffectToClip }: EffectsBrowserProps) {
const [effectsSearchQuery, setEffectsSearchQuery] = useState('')
return (
<div className="w-56 flex-shrink-0 bg-zinc-950 border-r border-zinc-800/80 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-zinc-800/80 bg-zinc-900/50">
<div className="w-5 h-5 rounded bg-blue-600/20 flex items-center justify-center">
<Sparkles className="h-3 w-3 text-blue-400" />
</div>
<span className="text-[11px] font-semibold text-zinc-200 flex-1">Effects</span>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Search */}
<div className="px-2.5 py-2 border-b border-zinc-800/60">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-600" />
<input
type="text"
placeholder="Search effects..."
value={effectsSearchQuery}
onChange={(e) => setEffectsSearchQuery(e.target.value)}
className="w-full pl-7 pr-2 py-1.5 bg-zinc-800/70 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 focus:bg-zinc-800 transition-colors"
/>
</div>
</div>
{/* Effect categories */}
<div className="flex-1 overflow-y-auto py-1.5 custom-scrollbar">
{(['filter', 'stylize', 'color-preset'] as const).map(category => {
const categoryLabel = category === 'filter' ? 'Filters' : category === 'stylize' ? 'Stylize' : 'Color Presets'
const categoryIcon = category === 'filter' ? 'filter' : category === 'stylize' ? 'stylize' : 'color'
const effects = (Object.entries(EFFECT_DEFINITIONS) as [EffectType, typeof EFFECT_DEFINITIONS[EffectType]][])
.filter(([_, def]) => def.category === category)
.filter(([_, def]) => !effectsSearchQuery || def.name.toLowerCase().includes(effectsSearchQuery.toLowerCase()))
if (effects.length === 0) return null
return (
<div key={category} className="mb-1">
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[9px] font-bold text-zinc-500 uppercase tracking-[0.08em]">
<div className={`w-1 h-1 rounded-full ${categoryIcon === 'filter' ? 'bg-blue-400' : categoryIcon === 'stylize' ? 'bg-amber-400' : 'bg-emerald-400'}`} />
{categoryLabel}
</div>
<div className="px-2 space-y-px">
{effects.map(([type, def]) => {
const lutGradient: Record<string, string> = {
'lut-cinematic': 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
'lut-vintage': 'linear-gradient(135deg, #d4a373 0%, #e6ccb2 50%, #b5838d 100%)',
'lut-bw': 'linear-gradient(135deg, #111 0%, #666 50%, #ccc 100%)',
'lut-cool': 'linear-gradient(135deg, #4cc9f0 0%, #4895ef 50%, #4361ee 100%)',
'lut-warm': 'linear-gradient(135deg, #f77f00 0%, #fcbf49 50%, #eae2b7 100%)',
'lut-muted': 'linear-gradient(135deg, #6b7280 0%, #9ca3af 50%, #d1d5db 100%)',
'lut-vivid': 'linear-gradient(135deg, #ff006e 0%, #fb5607 50%, #ffbe0b 100%)',
}
const filterIcon: Record<string, string> = {
'blur': 'B', 'sharpen': 'S', 'glow': 'G', 'vignette': 'V', 'grain': 'N',
}
const filterColor: Record<string, string> = {
'blur': 'from-blue-500/20 to-blue-600/10 text-blue-400',
'sharpen': 'from-cyan-500/20 to-cyan-600/10 text-cyan-400',
'glow': 'from-amber-500/20 to-amber-600/10 text-amber-400',
'vignette': 'from-blue-500/20 to-blue-600/10 text-blue-400',
'grain': 'from-stone-500/20 to-stone-600/10 text-stone-400',
}
return (
<button
key={type}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('effectType', type)
e.dataTransfer.effectAllowed = 'copy'
}}
onDoubleClick={() => {
if (selectedClip) addEffectToClip(selectedClip.id, type)
}}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-zinc-800/80 transition-all cursor-grab active:cursor-grabbing group"
title={`${def.name} — drag onto clip or double-click to apply`}
>
{/* Icon/swatch */}
{category === 'color-preset' ? (
<div
className="w-7 h-7 rounded-md flex-shrink-0 ring-1 ring-white/10 group-hover:ring-white/20 transition-all"
style={{ background: lutGradient[type] || 'linear-gradient(135deg, #333, #555)' }}
/>
) : (
<div className={`w-7 h-7 rounded-md flex-shrink-0 bg-gradient-to-br ${filterColor[type] || 'from-zinc-700 to-zinc-800 text-zinc-400'} flex items-center justify-center ring-1 ring-white/5 group-hover:ring-white/15 transition-all`}>
<span className="text-[11px] font-black">{filterIcon[type] || 'F'}</span>
</div>
)}
{/* Label */}
<span className="text-[11px] text-zinc-400 group-hover:text-zinc-200 transition-colors truncate">{def.name}</span>
</button>
)
})}
</div>
</div>
)
})}
</div>
{/* Footer hint */}
<div className="px-3 py-2 border-t border-zinc-800/60 bg-zinc-900/30">
<p className="text-[9px] text-zinc-600 leading-relaxed">Drag onto a clip or double-click to apply to selection</p>
</div>
</div>
)
}
@@ -0,0 +1,579 @@
import React, { useMemo, useEffect, useState, useRef } from 'react'
import {
X, Upload, Video, Image,
Loader2, Sparkles, RefreshCw, Info
} from 'lucide-react'
import { pathToFileUrl } from '../../lib/file-url'
import { SettingsPanel } from '../../components/SettingsPanel'
import type { GenerationSettings } from '../../components/SettingsPanel'
import type { GenerationMode } from '../../components/SettingsPanel'
import type { VideoGenerationModelSpecItem } from '../../lib/video-generation-model-specs'
interface TimelineGap {
trackIndex: number
startTime: number
endTime: number
}
type GapGenerateMode = 'text-to-video' | 'image-to-video' | 'text-to-image'
interface GapGenerationModalProps {
selectedGap: TimelineGap | null
anchorPosition?: { x: number; gapTop: number; gapBottom: number } | null
gapGenerateMode: GapGenerateMode | null
setGapGenerateMode: (mode: GapGenerateMode | null) => void
gapPrompt: string
setGapPrompt: (prompt: string) => void
gapSuggesting: boolean
gapSuggestion: string | null
gapBeforeFrame: string | null
gapAfterFrame: string | null
gapSettings: GenerationSettings
setGapSettings: (settings: GenerationSettings) => void
gapVideoModelSpecs: VideoGenerationModelSpecItem[]
gapVideoSettingsMessage?: string | null
gapCanGenerateVideo: boolean
gapImageFile: File | null
setGapImageFile: (file: File | null) => void
gapImageInputRef: React.RefObject<HTMLInputElement>
isRegenerating: boolean
regenStatusMessage: string
regenProgress: number
regenReset: () => void
handleGapGenerate: () => void
handleCloseGap: () => void
setSelectedGap: (gap: TimelineGap | null) => void
gapApplyAudioToTrack: boolean
setGapApplyAudioToTrack: (v: boolean) => void
regenerateSuggestion: () => void
gapSuggestionError?: boolean
gapSuggestionNoApiKey?: boolean
}
export function GapGenerationModal({
selectedGap,
gapGenerateMode,
setGapGenerateMode,
gapPrompt,
setGapPrompt,
gapSuggesting,
gapSuggestion,
gapBeforeFrame,
gapAfterFrame,
gapSettings,
setGapSettings,
gapVideoModelSpecs,
gapVideoSettingsMessage,
gapCanGenerateVideo,
gapImageFile,
setGapImageFile,
gapImageInputRef,
isRegenerating,
regenStatusMessage,
regenProgress,
regenReset,
handleGapGenerate,
handleCloseGap,
setSelectedGap,
gapApplyAudioToTrack,
setGapApplyAudioToTrack,
regenerateSuggestion,
gapSuggestionError,
gapSuggestionNoApiKey,
anchorPosition,
}: GapGenerationModalProps) {
if (!selectedGap) return null
const isVideoMode = gapGenerateMode === 'text-to-video' || gapGenerateMode === 'image-to-video'
const isImageMode = gapGenerateMode === 'text-to-image'
const gapImageUrl = useMemo(() => {
if (!gapImageFile) return null
return URL.createObjectURL(gapImageFile)
}, [gapImageFile])
const modalTitle = isVideoMode
? (gapImageFile ? 'Image to Video' : 'Generate Video')
: 'Generate Image'
const settingsMode: GenerationMode = isVideoMode
? (gapImageFile ? 'image-to-video' : 'text-to-video')
: 'text-to-image'
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
if (gapGenerateMode) {
setGapGenerateMode(null)
regenReset()
} else {
setSelectedGap(null)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [gapGenerateMode, setGapGenerateMode, regenReset, setSelectedGap])
const [startFrameEnabled, setStartFrameEnabled] = useState(true)
const [endFrameEnabled, setEndFrameEnabled] = useState(false)
const [startFrameOverride, setStartFrameOverride] = useState<string | null>(null)
const [endFrameOverride, setEndFrameOverride] = useState<string | null>(null)
const startFrameInputRef = useRef<HTMLInputElement>(null)
const endFrameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setStartFrameEnabled(true)
setEndFrameEnabled(false)
setStartFrameOverride(null)
setEndFrameOverride(null)
}, [gapGenerateMode])
const displayedBeforeFrame = startFrameOverride ?? gapBeforeFrame
const displayedAfterFrame = endFrameOverride ?? gapAfterFrame
const handleFrameFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
setter: (v: string | null) => void,
onSelect: () => void
) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => { setter(ev.target?.result as string); onSelect() }
reader.readAsDataURL(file)
e.target.value = ''
}
return (
<>
{gapGenerateMode && (
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-black/60 backdrop-blur-sm p-4 overflow-y-auto">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[420px] max-h-[calc(100vh-2rem)] flex flex-col overflow-hidden my-auto shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-xl flex items-center justify-center ${
isVideoMode ? 'bg-blue-600/20' : 'bg-emerald-600/20'
}`}>
{isVideoMode
? <Video className="h-4 w-4 text-blue-400" />
: <Image className="h-4 w-4 text-emerald-400" />}
</div>
<div>
<h2 className="text-sm font-semibold text-white">{modalTitle}</h2>
<p className="text-[11px] text-zinc-500">
Fill {(selectedGap.endTime - selectedGap.startTime).toFixed(1)}s gap on Track {selectedGap.trackIndex + 1}
</p>
</div>
</div>
<button
onClick={() => { setGapGenerateMode(null); regenReset() }}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Timeline visualization */}
<div className="px-5 pt-4 pb-2 space-y-2">
{/* Label + toggle row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-zinc-400 font-medium">
{isVideoMode ? 'Generate from' : 'Context frames'}
</span>
<div className="relative group/info">
<Info className="h-3 w-3 text-zinc-600 cursor-help" />
<div className="absolute left-0 top-full mt-2 w-60 p-2.5 bg-zinc-800 border border-zinc-700 rounded-lg text-[10px] text-zinc-300 leading-relaxed invisible group-hover/info:visible opacity-0 group-hover/info:opacity-100 transition-opacity pointer-events-none shadow-xl z-20">
{isVideoMode ? (
<>
<p>Only one conditioning frame can be used at a time.</p>
<p className="mt-1.5">If <strong className="text-white">End frame</strong> is selected, it will be treated as the start frame, since the model does not currently support generating from an end frame. The video will then be generated from that frame and played in reverse.</p>
</>
) : (
<p>Select frames from adjacent clips to provide context for the prompt.</p>
)}
</div>
</div>
</div>
{/* Segmented toggle */}
<div className="flex bg-zinc-800 rounded-lg p-0.5 gap-0.5">
<button
onClick={() => { if (startFrameEnabled) { setStartFrameEnabled(false) } else { setStartFrameEnabled(true); setEndFrameEnabled(false) } }}
className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
startFrameEnabled ? 'bg-zinc-600 text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
Start frame
</button>
<button
onClick={() => { if (endFrameEnabled) { setEndFrameEnabled(false) } else { setEndFrameEnabled(true); setStartFrameEnabled(false) } }}
className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
endFrameEnabled ? 'bg-zinc-600 text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
End frame
</button>
</div>
</div>
{/* Frame strip */}
<div className="flex h-[96px] rounded-xl overflow-hidden bg-zinc-950 border border-zinc-800">
{/* Before frame */}
{displayedBeforeFrame ? (
<div
className="relative w-[38%] h-full flex-shrink-0 overflow-hidden rounded-l-xl group/before cursor-pointer"
onClick={() => { if (startFrameEnabled) { setStartFrameEnabled(false) } else { setStartFrameEnabled(true); setEndFrameEnabled(false) } }}
>
<img
src={pathToFileUrl(displayedBeforeFrame)}
alt=""
className={`w-full h-full object-cover transition-all duration-300 ${
!startFrameEnabled ? 'grayscale opacity-50' : ''
}`}
/>
{/* Replace button */}
<div className="absolute top-1 left-1 inline-flex items-start opacity-0 group-hover/before:opacity-100 transition-all group/replace-start">
<button
onClick={(e) => { e.stopPropagation(); startFrameInputRef.current?.click() }}
className="p-1 rounded bg-black/50 text-zinc-400 hover:text-white hover:bg-black/75"
>
<Upload className="h-2.5 w-2.5" />
</button>
<div className="absolute left-0 top-full mt-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[9px] text-zinc-300 whitespace-nowrap invisible group-hover/replace-start:visible pointer-events-none z-30">
Replace image
</div>
</div>
{/* Selection border */}
{startFrameEnabled && (
<div className="absolute inset-0 rounded-l-xl border-2 border-blue-500 pointer-events-none" />
)}
</div>
) : (
<div className="w-[38%] h-full flex-shrink-0 bg-zinc-800 flex items-center justify-center">
<span className="text-zinc-600 text-[8px]">No clip</span>
</div>
)}
{/* Center gap area */}
<div className="flex-1 relative overflow-hidden">
{isVideoMode && gapImageFile && gapImageUrl ? (
<div className="relative w-full h-full group/center">
<img src={gapImageUrl} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 ring-2 ring-inset ring-blue-500/50 pointer-events-none" />
<button
onClick={() => setGapImageFile(null)}
className="absolute top-1 right-1 p-0.5 rounded-full bg-black/70 text-white/70 hover:text-red-400 opacity-0 group-hover/center:opacity-100 transition-opacity"
>
<X className="h-2.5 w-2.5" />
</button>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/70 to-transparent py-1 px-2 flex items-center justify-center">
<span className="text-[8px] text-blue-200/90 font-medium">
Source frame
</span>
</div>
</div>
) : (
<div className="w-full h-full relative">
<div className="absolute inset-0 bg-zinc-800/70" />
<div className="absolute inset-0 border border-dashed border-zinc-700" />
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 px-3">
<Sparkles className="h-3.5 w-3.5 text-blue-400/40" />
<span className="text-xs text-zinc-500 font-medium text-center">AI fills this gap</span>
</div>
</div>
)}
</div>
{/* After frame */}
{displayedAfterFrame ? (
<div
className="relative w-[38%] h-full flex-shrink-0 overflow-hidden rounded-r-xl group/after cursor-pointer"
onClick={() => { if (endFrameEnabled) { setEndFrameEnabled(false) } else { setEndFrameEnabled(true); setStartFrameEnabled(false) } }}
>
<img
src={pathToFileUrl(displayedAfterFrame)}
alt=""
className={`w-full h-full object-cover transition-all duration-300 ${
!endFrameEnabled ? 'grayscale opacity-50' : ''
}`}
/>
{/* Replace button */}
<div className="absolute top-1 left-1 inline-flex items-start opacity-0 group-hover/after:opacity-100 transition-all group/replace-end">
<button
onClick={(e) => { e.stopPropagation(); endFrameInputRef.current?.click() }}
className="p-1 rounded bg-black/50 text-zinc-400 hover:text-white hover:bg-black/75"
>
<Upload className="h-2.5 w-2.5" />
</button>
<div className="absolute left-0 top-full mt-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[9px] text-zinc-300 whitespace-nowrap invisible group-hover/replace-end:visible pointer-events-none z-30">
Replace image
</div>
</div>
{/* Selection border */}
{endFrameEnabled && (
<div className="absolute inset-0 rounded-r-xl border-2 border-blue-500 pointer-events-none" />
)}
</div>
) : (
<div className="w-[38%] h-full flex-shrink-0 bg-zinc-800 flex items-center justify-center">
<span className="text-zinc-600 text-[8px]">No clip</span>
</div>
)}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto px-5 py-4 space-y-4">
{/* Prompt */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-zinc-500 uppercase font-semibold">Prompt</label>
<div className="flex items-center gap-2">
{gapSuggesting && (
<div className="flex items-center gap-1.5 text-[10px] text-amber-400/80">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Analyzing...</span>
</div>
)}
{!gapSuggesting && gapSuggestion && gapPrompt === gapSuggestion && (
<div className="flex items-center gap-1 text-[10px] text-emerald-400/70">
<Sparkles className="h-3 w-3" />
<span>AI-suggested</span>
</div>
)}
{!gapSuggesting && !gapSuggestionNoApiKey && (
<button
onClick={regenerateSuggestion}
className="flex items-center gap-1 text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors px-1.5 py-0.5 rounded hover:bg-zinc-800"
title="Re-analyze surrounding clips and generate a new prompt suggestion"
>
<RefreshCw className="h-3 w-3" />
<span>Re-analyze</span>
</button>
)}
</div>
</div>
<div className="relative">
<textarea
value={gapPrompt}
onChange={(e) => setGapPrompt(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder={gapSuggesting
? 'Analyzing surrounding shots for context...'
: isImageMode
? 'Describe the image to generate...'
: (gapImageFile ? 'Describe the video to generate from the image...' : 'Describe the video shot to generate...')}
className={`w-full bg-zinc-800 border rounded-xl p-3 text-sm text-white resize-none focus:outline-none focus:ring-1 placeholder-zinc-600 ${
gapSuggesting
? 'border-amber-600/40 focus:border-amber-500/50 focus:ring-amber-500/30 animate-pulse'
: 'border-zinc-700 focus:border-blue-500/50 focus:ring-blue-500/30'
}`}
rows={3}
/>
{gapSuggestion && gapPrompt !== gapSuggestion && !gapSuggesting && (
<button
onClick={() => setGapPrompt(gapSuggestion)}
className="absolute top-1.5 right-1.5 px-2 py-1 rounded-lg bg-amber-900/40 border border-amber-700/30 text-amber-300 text-[10px] hover:bg-amber-900/60 transition-colors flex items-center gap-1"
title="Use AI-suggested prompt"
>
<Sparkles className="h-2.5 w-2.5" />
Use suggestion
</button>
)}
</div>
{gapSuggestionNoApiKey && (
<div className="mt-1.5 space-y-1.5">
<p className="text-[10px] text-zinc-500">
Gemini API key required for AI prompt suggestions.
</p>
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }))
}}
className="px-2.5 py-1 bg-blue-600 text-white text-[10px] rounded hover:bg-blue-500 transition-colors"
>
Configure in Settings
</button>
</div>
)}
{gapSuggestionError && !gapSuggesting && !gapSuggestion && (
<p className="text-[10px] text-zinc-500 mt-1.5">Could not suggest a prompt. Type your own or try again.</p>
)}
</div>
{/* Settings */}
<div className="[&_select]:h-8 [&_select]:text-xs [&_select]:py-1 [&_label]:text-[10px] [&_label]:mb-1">
<SettingsPanel
settings={gapSettings}
onSettingsChange={setGapSettings}
disabled={isRegenerating}
mode={settingsMode}
videoModelSpecs={gapVideoModelSpecs}
minimumDuration={selectedGap.endTime - selectedGap.startTime}
hideDuration={isVideoMode}
videoSettingsMessage={gapVideoSettingsMessage}
/>
</div>
{/* Apply audio to audio track toggle — only in video mode when audio is on */}
{isVideoMode && gapSettings.audio && (
<div
className={`flex items-center justify-between px-1 py-2 ${
isRegenerating ? 'opacity-40 pointer-events-none' : 'cursor-pointer'
}`}
onClick={() => !isRegenerating && setGapApplyAudioToTrack(!gapApplyAudioToTrack)}
>
<div>
<span className="text-xs text-zinc-300">Apply audio to audio track</span>
<p className="text-[10px] text-zinc-500 mt-0.5">Place the generated audio as a linked clip on the audio track</p>
</div>
<div className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
gapApplyAudioToTrack ? 'bg-blue-600' : 'bg-zinc-700'
}`}>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform pointer-events-none ${
gapApplyAudioToTrack ? 'translate-x-4' : 'translate-x-0'
}`} />
</div>
</div>
)}
{/* Progress */}
{isRegenerating && (
<div className="bg-zinc-800 rounded-xl p-3 border border-zinc-700">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin" />
<span className="text-xs text-zinc-300">{regenStatusMessage || 'Generating...'}</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${regenProgress * 100}%` }}
/>
</div>
</div>
)}
</div>
{/* Hidden file input */}
<input
ref={gapImageInputRef}
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setGapImageFile(file)
if (gapImageInputRef.current) gapImageInputRef.current.value = ''
}}
className="hidden"
/>
{/* Footer */}
<div className="px-5 py-3 flex items-center justify-end gap-2">
<button
onClick={() => { setGapGenerateMode(null); regenReset() }}
className="px-3 py-1.5 rounded-md bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleGapGenerate}
disabled={isRegenerating || !gapPrompt.trim() || (isVideoMode && !gapCanGenerateVideo)}
className="px-4 py-1.5 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-500 transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{isRegenerating ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Generating...
</>
) : (
'Generate'
)}
</button>
</div>
</div>
</div>
)}
{/* Gap action bar - shown when gap is selected but no generate mode yet */}
{!gapGenerateMode && (() => {
// Smart positioning: anchor to the clicked gap, with edge-case clamping
const POPOVER_W = 200
const POPOVER_H = 136
const GAP_PX = 4
const MARGIN = 8
const vw = typeof window !== 'undefined' ? window.innerWidth : 1280
const vh = typeof window !== 'undefined' ? window.innerHeight : 800
const cx = anchorPosition?.x ?? vw / 2
const gapTop = anchorPosition?.gapTop ?? vh - 220 - 52
const gapBottom = anchorPosition?.gapBottom ?? vh - 220
// Horizontal: center on gap, clamped so popover stays in viewport
const left = Math.max(MARGIN, Math.min(cx - POPOVER_W / 2, vw - POPOVER_W - MARGIN))
// Vertical: prefer below gap, flip above if not enough space below
const spaceBelow = vh - gapBottom - GAP_PX
const openAbove = spaceBelow < POPOVER_H + MARGIN
const rawTop = openAbove ? gapTop - GAP_PX - POPOVER_H : gapBottom + GAP_PX
const top = Math.max(MARGIN, Math.min(rawTop, vh - POPOVER_H - MARGIN))
return (
<>
<div className="fixed inset-0 z-[90]" onClick={() => setSelectedGap(null)} />
<div
className="fixed z-[100] bg-zinc-950 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden py-1"
style={{ left, top, width: POPOVER_W }}
>
{/* Title */}
<p className="text-[10px] text-zinc-500 font-medium px-3 pt-1.5 pb-1.5">
{(selectedGap.endTime - selectedGap.startTime).toFixed(1)}s gap selected
</p>
<div className="h-px bg-zinc-800 mx-0 mb-1" />
{/* Menu items */}
<button
onClick={() => setGapGenerateMode('text-to-image')}
className="w-full px-3 py-1.5 text-left text-xs text-zinc-200 hover:bg-zinc-800 transition-colors"
>
Fill with Image
</button>
<button
onClick={() => setGapGenerateMode('text-to-video')}
className="w-full px-3 py-1.5 text-left text-xs text-zinc-200 hover:bg-zinc-800 transition-colors"
>
Fill with Video
</button>
<div className="h-px bg-zinc-800 mx-0 my-1" />
<button
onClick={handleCloseGap}
className="w-full px-3 py-1.5 text-left text-xs text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300 transition-colors flex items-center justify-between"
>
<span>Close gap</span>
<kbd className="px-1 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-500 text-[9px] font-mono leading-none">Del</kbd>
</button>
</div>
</>
)
})()}
{/* Hidden file inputs for replacing start/end frames */}
<input
ref={startFrameInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFrameFileChange(e, setStartFrameOverride, () => { setStartFrameEnabled(true); setEndFrameEnabled(false) })}
/>
<input
ref={endFrameInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFrameFileChange(e, setEndFrameOverride, () => { setEndFrameEnabled(true); setStartFrameEnabled(false) })}
/>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
import { useMemo } from 'react'
import { MessageSquare, Trash2 } from 'lucide-react'
import type { SubtitleStyle } from '../../types/project-model'
import { DEFAULT_SUBTITLE_STYLE } from '../../types/project-model'
import {
selectSelectedSubtitleId,
selectSubtitles,
selectTracks,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
export function SubtitlePropertiesPanel() {
const { updateSubtitle, deleteSubtitle } = useEditorActions()
const selectedSubtitleId = useEditorStore(selectSelectedSubtitleId)
const subtitles = useEditorStore(selectSubtitles)
const tracks = useEditorStore(selectTracks)
const selectedSub = useMemo(
() => subtitles.find(subtitle => subtitle.id === selectedSubtitleId) ?? null,
[selectedSubtitleId, subtitles],
)
if (!selectedSub) return null
const trackStyle = useMemo(() => {
const track = tracks[selectedSub.trackIndex]
return {
...(track?.subtitleStyle || {}),
...(selectedSub.style || {}),
}
}, [selectedSub, tracks])
const subStyle = { ...DEFAULT_SUBTITLE_STYLE, ...trackStyle, ...selectedSub.style }
return (
<div className="h-full border-l border-zinc-800 bg-zinc-900 p-4 overflow-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-amber-400 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Subtitle
</h3>
<button
onClick={() => deleteSubtitle(selectedSub.id)}
className="p-1 rounded hover:bg-red-900/30 text-zinc-500 hover:text-red-400"
title="Delete subtitle"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="space-y-4">
{/* Subtitle text */}
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Text</label>
<textarea
value={selectedSub.text}
onChange={(e) => updateSubtitle(selectedSub.id, { text: e.target.value })}
onKeyDown={(e) => e.stopPropagation()}
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg p-2.5 text-sm text-white resize-none focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/30"
rows={3}
placeholder="Enter subtitle text..."
/>
</div>
{/* Timing */}
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Timing</label>
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-[9px] text-zinc-500 block mb-1">Start</span>
<input
type="number"
step={0.1}
min={0}
value={parseFloat(selectedSub.startTime.toFixed(2))}
onChange={(e) => {
const v = parseFloat(e.target.value)
if (!isNaN(v) && v >= 0 && v < selectedSub.endTime) {
updateSubtitle(selectedSub.id, { startTime: v })
}
}}
onKeyDown={(e) => e.stopPropagation()}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-white font-mono focus:outline-none focus:border-amber-500/50"
/>
</div>
<div>
<span className="text-[9px] text-zinc-500 block mb-1">End</span>
<input
type="number"
step={0.1}
min={0}
value={parseFloat(selectedSub.endTime.toFixed(2))}
onChange={(e) => {
const v = parseFloat(e.target.value)
if (!isNaN(v) && v > selectedSub.startTime) {
updateSubtitle(selectedSub.id, { endTime: v })
}
}}
onKeyDown={(e) => e.stopPropagation()}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-white font-mono focus:outline-none focus:border-amber-500/50"
/>
</div>
</div>
<span className="text-[9px] text-zinc-600 mt-1 block">
Duration: {(selectedSub.endTime - selectedSub.startTime).toFixed(2)}s
</span>
</div>
{/* Style */}
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Style</label>
<div className="space-y-2">
{/* Font size */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Font Size</span>
<input
type="number"
min={12}
max={96}
value={subStyle.fontSize}
onChange={(e) => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, fontSize: parseInt(e.target.value) || 32 } })}
onKeyDown={(e) => e.stopPropagation()}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white text-center focus:outline-none focus:border-amber-500/50"
/>
</div>
{/* Bold / Italic */}
<div className="flex items-center gap-2">
<button
onClick={() => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, fontWeight: subStyle.fontWeight === 'bold' ? 'normal' : 'bold' } })}
className={`px-2.5 py-1 rounded text-[10px] font-bold ${subStyle.fontWeight === 'bold' ? 'bg-amber-600/30 text-amber-300 border border-amber-500/40' : 'bg-zinc-800 text-zinc-400 border border-zinc-700'}`}
>
B
</button>
<button
onClick={() => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, italic: !subStyle.italic } })}
className={`px-2.5 py-1 rounded text-[10px] italic ${subStyle.italic ? 'bg-amber-600/30 text-amber-300 border border-amber-500/40' : 'bg-zinc-800 text-zinc-400 border border-zinc-700'}`}
>
I
</button>
</div>
{/* Text color */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Text Color</span>
<input
type="color"
value={subStyle.color}
onChange={(e) => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, color: e.target.value } })}
className="w-7 h-6 rounded cursor-pointer border border-zinc-700"
/>
</div>
{/* Background toggle + 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">
<button
onClick={() => updateSubtitle(selectedSub.id, {
style: { ...selectedSub.style, backgroundColor: subStyle.backgroundColor === 'transparent' ? '#000000AA' : 'transparent' }
})}
className={`px-2 py-0.5 rounded text-[9px] border ${
subStyle.backgroundColor !== 'transparent'
? 'bg-amber-600/20 text-amber-300 border-amber-500/40'
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
}`}
>
{subStyle.backgroundColor !== 'transparent' ? 'On' : 'Off'}
</button>
{subStyle.backgroundColor !== 'transparent' && (
<input
type="color"
value={subStyle.backgroundColor.slice(0, 7)}
onChange={(e) => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, backgroundColor: e.target.value + 'CC' } })}
className="w-7 h-6 rounded cursor-pointer border border-zinc-700"
/>
)}
</div>
</div>
{/* Position */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Position</span>
<select
value={subStyle.position}
onChange={(e) => updateSubtitle(selectedSub.id, { style: { ...selectedSub.style, position: e.target.value as SubtitleStyle['position'] } })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-amber-500/50"
>
<option value="bottom">Bottom</option>
<option value="center">Center</option>
<option value="top">Top</option>
</select>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,181 @@
import { useMemo } from 'react'
import { X, Palette } from 'lucide-react'
import type { SubtitleStyle } from '../../types/project-model'
import { DEFAULT_SUBTITLE_STYLE } from '../../types/project-model'
import { selectSubtitleTrackStyleIdx, selectTracks } from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
export function SubtitleTrackStyleEditor() {
const {
clearSubtitleOverridesForTrack,
setSubtitleTrackStyleEditorTrack,
updateSubtitleTrackStyle,
} = useEditorActions()
const trackIndex = useEditorStore(selectSubtitleTrackStyleIdx)
const tracks = useEditorStore(selectTracks)
const editorModel = useMemo(() => {
if (trackIndex === null) return null
const track = tracks[trackIndex]
if (!track || track.type !== 'subtitle') return null
return {
track,
trackIndex,
style: {
...DEFAULT_SUBTITLE_STYLE,
...(track.subtitleStyle || {}),
},
}
}, [trackIndex, tracks])
if (!editorModel) return null
const { track, trackIndex: activeTrackIndex, style } = editorModel
const closeEditor = () => {
setSubtitleTrackStyleEditorTrack()
}
const updateTrackStyle = (patch: Partial<SubtitleStyle>) => {
updateSubtitleTrackStyle(activeTrackIndex, patch)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={closeEditor}>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[380px] max-h-[80vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg flex items-center justify-center bg-amber-600/20">
<Palette className="h-3.5 w-3.5 text-amber-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Track Style</h2>
<p className="text-[10px] text-zinc-500">{track.name} - applies to all subtitles on this track</p>
</div>
</div>
<button onClick={closeEditor} className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-5 space-y-3">
{/* Preview */}
<div className="bg-zinc-950 rounded-lg p-4 flex items-center justify-center border border-zinc-800 min-h-[60px]">
<span
className="inline-block text-center rounded px-3 py-1.5 leading-snug"
style={{
fontSize: `${Math.min(style.fontSize, 28)}px`,
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
fontStyle: style.italic ? 'italic' : 'normal',
color: style.color,
backgroundColor: style.backgroundColor,
textShadow: '1px 1px 3px rgba(0,0,0,0.8)',
}}
>
Preview subtitle
</span>
</div>
{/* Font size */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Font Size</span>
<div className="flex items-center gap-2">
<input
type="range" min={16} max={72} value={style.fontSize}
onChange={e => updateTrackStyle({ fontSize: parseInt(e.target.value) })}
className="w-24 accent-amber-500"
/>
<span className="text-[10px] text-zinc-300 w-8 text-right tabular-nums">{style.fontSize}px</span>
</div>
</div>
{/* Font family */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Font</span>
<select
value={style.fontFamily}
onChange={e => updateTrackStyle({ fontFamily: e.target.value })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-amber-500/50"
>
<option value="sans-serif">Sans-Serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
<option value="'Arial', sans-serif">Arial</option>
<option value="'Helvetica Neue', sans-serif">Helvetica</option>
<option value="'Georgia', serif">Georgia</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="'Times New Roman', serif">Times New Roman</option>
</select>
</div>
{/* Bold / Italic */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Style</span>
<div className="flex items-center gap-2">
<button
onClick={() => updateTrackStyle({ fontWeight: style.fontWeight === 'bold' ? 'normal' : 'bold' })}
className={`px-2.5 py-1 rounded text-[10px] font-bold ${style.fontWeight === 'bold' ? 'bg-amber-600/30 text-amber-300 border border-amber-500/40' : 'bg-zinc-800 text-zinc-400 border border-zinc-700'}`}
>
B
</button>
<button
onClick={() => updateTrackStyle({ italic: !style.italic })}
className={`px-2.5 py-1 rounded text-[10px] italic ${style.italic ? 'bg-amber-600/30 text-amber-300 border border-amber-500/40' : 'bg-zinc-800 text-zinc-400 border border-zinc-700'}`}
>
I
</button>
</div>
</div>
{/* Text color */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Text Color</span>
<input type="color" value={style.color} onChange={e => updateTrackStyle({ color: e.target.value })} className="w-7 h-6 rounded cursor-pointer border border-zinc-700" />
</div>
{/* Background toggle + 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">
<button
onClick={() => updateTrackStyle({ backgroundColor: style.backgroundColor === 'transparent' ? '#000000AA' : 'transparent' })}
className={`px-2 py-0.5 rounded text-[9px] border ${style.backgroundColor !== 'transparent' ? 'bg-amber-600/20 text-amber-300 border-amber-500/40' : 'bg-zinc-800 text-zinc-500 border-zinc-700'}`}
>
{style.backgroundColor !== 'transparent' ? 'On' : 'Off'}
</button>
{style.backgroundColor !== 'transparent' && (
<input type="color" value={style.backgroundColor.slice(0, 7)} onChange={e => updateTrackStyle({ backgroundColor: e.target.value + 'CC' })} className="w-7 h-6 rounded cursor-pointer border border-zinc-700" />
)}
</div>
</div>
{/* Position */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Position</span>
<select
value={style.position}
onChange={e => updateTrackStyle({ position: e.target.value as SubtitleStyle['position'] })}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-[10px] text-white focus:outline-none focus:border-amber-500/50"
>
<option value="bottom">Bottom</option>
<option value="center">Center</option>
<option value="top">Top</option>
</select>
</div>
<div className="border-t border-zinc-800 pt-3 mt-3">
<button
onClick={() => {
clearSubtitleOverridesForTrack(activeTrackIndex)
setSubtitleTrackStyleEditorTrack()
}}
className="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-xs hover:bg-zinc-700 transition-colors text-center"
>
Apply to all &amp; reset overrides
</button>
</div>
</div>
</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import React, { type RefObject } from 'react'
import { Plus, Copy, Eye, Trash2 } from 'lucide-react'
import type { Asset, AssetTake } from '../../types/project-model'
import { useEditorActions } from './editor-store'
export interface TakeContextMenuProps {
tcAsset: Asset
take: AssetTake
takeIndex: number
takeContextMenu: { assetId: string; takeIndex: number; x: number; y: number }
takeContextMenuRef: RefObject<HTMLDivElement>
addClipToTimeline: (asset: Asset, trackIndex?: number, startTime?: number) => void
createAssetFromTake: (asset: Asset, take: AssetTake) => Asset
setTakeContextMenu: React.Dispatch<React.SetStateAction<{ assetId: string; takeIndex: number; x: number; y: number } | null>>
}
export function TakeContextMenu({
tcAsset,
take,
takeIndex,
takeContextMenu,
takeContextMenuRef,
addClipToTimeline,
createAssetFromTake,
setTakeContextMenu,
}: TakeContextMenuProps) {
const actions = useEditorActions()
const isActive = (tcAsset.activeTakeIndex ?? 0) === takeIndex
return (
<div
ref={takeContextMenuRef}
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[190px] text-xs"
style={{ left: takeContextMenu.x, top: takeContextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[10px] text-zinc-500 font-medium">
Take {takeIndex + 1} of {tcAsset.takes!.length}
</div>
{!isActive && (
<button
onClick={() => {
actions.setAssetActiveTake(tcAsset.id, takeIndex)
setTakeContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Eye className="h-3.5 w-3.5 text-zinc-500" />
<span>Set as Active Take</span>
</button>
)}
<button
onClick={() => {
addClipToTimeline({
...tcAsset,
path: take.path,
bigThumbnailPath: take.bigThumbnailPath,
smallThumbnailPath: take.smallThumbnailPath,
}, 0)
setTakeContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Plus className="h-3.5 w-3.5 text-zinc-500" />
<span>Add to Timeline</span>
</button>
<div className="h-px bg-zinc-700 my-1" />
<button
onClick={() => {
actions.addAssetToEditor(createAssetFromTake(tcAsset, take))
setTakeContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-blue-300 hover:bg-zinc-700 flex items-center gap-3"
>
<Copy className="h-3.5 w-3.5" />
<span>Create New Asset from Take</span>
</button>
{tcAsset.takes!.length > 1 && (
<>
<div className="h-px bg-zinc-700 my-1" />
<button
onClick={() => {
if (confirm(`Delete take ${takeIndex + 1}?`)) {
actions.deleteAssetTake(tcAsset.id, takeIndex)
}
setTakeContextMenu(null)
}}
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-red-900/30 flex items-center gap-3"
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Take</span>
</button>
</>
)}
</div>
)
}
+192
View File
@@ -0,0 +1,192 @@
import React from 'react'
import { Plus, Gauge, Download, Maximize2, Sparkles, FileUp, FileDown, ZoomOut, ZoomIn } from 'lucide-react'
import { Button } from '../../components/ui/button'
import { Tooltip } from '../../components/ui/tooltip'
import type { TimelineClip, Track, SubtitleClip } from '../../types/project-model'
interface TimelineToolbarProps {
selectedClip: TimelineClip | null
updateClip: (id: string, updates: Partial<TimelineClip>) => void
getMaxClipDuration: (clip: TimelineClip) => number
setShowExportModal: (v: boolean) => void
handleResetLayout: () => void
canUseIcLora: boolean
onICLoraClip: (clip: TimelineClip) => void
tracks: Track[]
subtitleFileInputRef: React.RefObject<HTMLInputElement>
handleImportSrt: (e: React.ChangeEvent<HTMLInputElement>) => void
handleExportSrt: () => void
subtitles: SubtitleClip[]
zoom: number
setZoom: (z: number) => void
getMinZoom: () => number
centerOnPlayheadRef: React.MutableRefObject<boolean>
handleFitToView: () => void
}
export function TimelineToolbar({
selectedClip, updateClip, getMaxClipDuration,
setShowExportModal, handleResetLayout,
canUseIcLora, onICLoraClip,
tracks, subtitleFileInputRef, handleImportSrt, handleExportSrt, subtitles,
zoom, setZoom, getMinZoom, centerOnPlayheadRef, handleFitToView,
}: TimelineToolbarProps) {
return (
<div className="h-9 bg-zinc-900 border-t border-zinc-800 flex items-center px-3 gap-2 flex-shrink-0">
<Button variant="outline" size="sm" className="h-6 border-zinc-700 text-zinc-400 text-[10px] px-2">
<Plus className="h-3 w-3 mr-1" />
Add Clip
</Button>
{selectedClip && (
<>
<div className="w-px h-4 bg-zinc-700" />
<div className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<Gauge className="h-3 w-3" />
<select
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="bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5 text-[10px] text-white"
>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
<option value={4}>4x</option>
</select>
</div>
</>
)}
<div className="w-px h-4 bg-zinc-700" />
<Button
variant="outline"
size="sm"
className="h-6 border-zinc-700 text-zinc-400 text-[10px] px-2"
onClick={() => setShowExportModal(true)}
>
<Download className="h-3 w-3 mr-1" />
Export
</Button>
<Button
variant="outline"
size="sm"
className="h-6 border-zinc-700 text-zinc-400 text-[10px] px-2"
onClick={handleResetLayout}
title="Reset panel sizes to default"
>
<Maximize2 className="h-3 w-3 mr-1" />
Layout
</Button>
<div className="w-px h-4 bg-zinc-700" />
{canUseIcLora && (
<Button
variant="outline"
size="sm"
className="h-6 border-amber-700/50 text-amber-400 text-[10px] px-2 hover:bg-amber-900/30"
onClick={() => {
if (selectedClip?.type === 'video') {
onICLoraClip(selectedClip)
}
}}
disabled={selectedClip?.type !== 'video'}
title="Open IC-LoRA style transfer panel"
>
<Sparkles className="h-3 w-3 mr-1" />
IC-LoRA
</Button>
)}
{/* Subtitle import/export */}
{tracks.some(t => t.type === 'subtitle') && (
<>
<div className="w-px h-4 bg-zinc-700" />
<div className="flex items-center gap-1">
<button
onClick={() => subtitleFileInputRef.current?.click()}
className="h-6 px-2 rounded bg-amber-900/30 border border-amber-700/30 text-amber-400 hover:bg-amber-900/50 text-[10px] flex items-center gap-1 transition-colors"
title="Import SRT subtitles"
>
<FileUp className="h-3 w-3" />
Import SRT
</button>
<button
onClick={handleExportSrt}
disabled={subtitles.length === 0}
className="h-6 px-2 rounded bg-amber-900/30 border border-amber-700/30 text-amber-400 hover:bg-amber-900/50 text-[10px] flex items-center gap-1 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Export SRT subtitles"
>
<FileDown className="h-3 w-3" />
Export SRT
</button>
</div>
<input
ref={subtitleFileInputRef}
type="file"
accept=".srt"
onChange={handleImportSrt}
className="hidden"
/>
</>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Zoom slider bar */}
<div className="flex items-center gap-2">
<Tooltip content="Zoom out (-)">
<button
onClick={() => { centerOnPlayheadRef.current = true; setZoom(Math.max(getMinZoom(), +(zoom - 0.25).toFixed(2))) }}
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
</Tooltip>
<input
type="range"
min={Math.max(1, Math.round(getMinZoom() * 100))}
max={400}
step={5}
value={Math.round(zoom * 100)}
onChange={(e) => { centerOnPlayheadRef.current = true; setZoom(Math.max(getMinZoom(), +(parseInt(e.target.value) / 100).toFixed(2))) }}
className="w-28 h-1 accent-blue-500 cursor-pointer"
title={`Zoom: ${Math.round(zoom * 100)}%`}
/>
<Tooltip content="Zoom in (+)">
<button
onClick={() => { centerOnPlayheadRef.current = true; setZoom(Math.min(4, +(zoom + 0.25).toFixed(2))) }}
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</Tooltip>
<span className="text-[10px] text-zinc-500 tabular-nums w-8 text-right">{Math.round(zoom * 100)}%</span>
<Tooltip content="Fit to view (Ctrl+0)">
<button
onClick={handleFitToView}
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors ml-0.5"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import React from 'react'
import { Magnet, Type } from 'lucide-react'
import { PRIMARY_TOOLS, TRIM_TOOLS, ToolType } from './video-editor-utils'
import { getShortcutLabel, type KeyboardLayout } from './video-editor-utils'
import { Tooltip } from '@/components/ui/tooltip'
interface ToolsPanelProps {
activeTool: ToolType
setActiveTool: (t: ToolType) => void
lastTrimTool: ToolType
setLastTrimTool: (t: ToolType) => void
showTrimFlyout: boolean
setShowTrimFlyout: (v: boolean) => void
trimFlyoutOpenedRef: React.MutableRefObject<boolean>
trimLongPressRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>
snapEnabled: boolean
setSnapEnabled: (v: boolean) => void
showEffectsBrowser: boolean
setShowEffectsBrowser: (v: boolean) => void
addTextClip: () => void
kbLayout: KeyboardLayout
}
export function ToolsPanel({
activeTool, setActiveTool,
lastTrimTool, setLastTrimTool,
showTrimFlyout, setShowTrimFlyout,
trimFlyoutOpenedRef, trimLongPressRef,
snapEnabled, setSnapEnabled,
showEffectsBrowser: _showEffectsBrowser, setShowEffectsBrowser: _setShowEffectsBrowser, // EFFECTS HIDDEN
addTextClip, kbLayout,
}: ToolsPanelProps) {
return (
<div className="w-10 flex-shrink-0 bg-zinc-900 border-r border-zinc-800 flex flex-col items-center py-1 gap-0.5 overflow-y-auto">
{PRIMARY_TOOLS.map(tool => (
<Tooltip
key={tool.id}
side="right"
content={(() => { const s = getShortcutLabel(kbLayout, tool.actionId); return <>{tool.label}{s && <span className="text-zinc-400"> ({s})</span>}</>; })()}
>
<button
onClick={() => setActiveTool(tool.id)}
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 ${
activeTool === tool.id
? 'bg-blue-600 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
}`}
>
<tool.icon className="h-4 w-4" />
</button>
</Tooltip>
))}
{/* Trim tools group button */}
{(() => {
const trimToolIds = new Set(TRIM_TOOLS.map(t => t.id))
const isTrimActive = trimToolIds.has(activeTool)
const currentTrimTool = TRIM_TOOLS.find(t => t.id === (isTrimActive ? activeTool : lastTrimTool)) || TRIM_TOOLS[0]
return (
<div className="relative flex-shrink-0">
<Tooltip
side="right"
content={(() => { const s = getShortcutLabel(kbLayout, currentTrimTool.actionId); return <>{currentTrimTool.label}<span className="text-zinc-400">{s ? ` (${s}) — ` : ' — '}right-click or hold for more</span></>; })()}
>
<button
onClick={() => {
if (trimFlyoutOpenedRef.current) { trimFlyoutOpenedRef.current = false; return }
setActiveTool(currentTrimTool.id)
setLastTrimTool(currentTrimTool.id)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
trimFlyoutOpenedRef.current = true
setShowTrimFlyout(true)
}}
onMouseDown={(e) => {
if (e.button !== 0) return
trimFlyoutOpenedRef.current = false
trimLongPressRef.current = setTimeout(() => {
trimLongPressRef.current = null
trimFlyoutOpenedRef.current = true
setShowTrimFlyout(true)
}, 400)
}}
onMouseUp={() => {
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
}}
onMouseLeave={() => {
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
}}
data-trim-group-btn=""
className={`p-1.5 rounded-lg transition-colors relative ${
isTrimActive
? 'bg-blue-600 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
}`}
>
<currentTrimTool.icon className="h-4 w-4" />
<div className="absolute bottom-0 right-0 w-0 h-0 border-l-[4px] border-l-transparent border-b-[4px] border-b-current opacity-60" />
</button>
</Tooltip>
{showTrimFlyout && (() => {
const btnEl = document.querySelector('[data-trim-group-btn]')
const rect = btnEl?.getBoundingClientRect()
return (
<>
<div className="fixed inset-0 z-[9998]" onMouseDown={() => setShowTrimFlyout(false)} onContextMenu={(e) => { e.preventDefault(); setShowTrimFlyout(false) }} />
<div
className="fixed bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-[9999] min-w-[160px]"
style={{ top: rect?.top ?? 0, left: (rect?.right ?? 44) + 4 }}
>
{TRIM_TOOLS.map(t => (
<button
key={t.id}
onClick={() => {
setActiveTool(t.id)
setLastTrimTool(t.id)
setShowTrimFlyout(false)
}}
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
activeTool === t.id ? 'bg-blue-600/30 text-white' : 'text-zinc-300 hover:bg-zinc-700'
}`}
>
<t.icon className="h-3.5 w-3.5" />
<span className="flex-1">{t.label}</span>
<span className="text-zinc-500 text-[10px]">{getShortcutLabel(kbLayout, t.actionId)}</span>
</button>
))}
</div>
</>
)
})()}
</div>
)
})()}
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
<Tooltip side="right" content={snapEnabled ? 'Snapping On' : 'Snapping Off'}>
<button
onClick={() => setSnapEnabled(!snapEnabled)}
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 ${
snapEnabled
? 'bg-blue-600 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
}`}
>
<Magnet className="h-4 w-4" />
</button>
</Tooltip>
{/* EFFECTS HIDDEN - FX button hidden because effects are not applied during export
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
<Tooltip side="right" content="Effects Browser">
<button
onClick={() => setShowEffectsBrowser(!showEffectsBrowser)}
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 text-[10px] font-bold ${
showEffectsBrowser
? 'bg-blue-600 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
}`}
>
FX
</button>
</Tooltip>
EFFECTS HIDDEN */}
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
<Tooltip side="right" content="Add Text Overlay">
<button
onClick={() => addTextClip()}
className="p-1.5 rounded-lg transition-colors flex-shrink-0 text-cyan-400 hover:bg-cyan-900/30 hover:text-cyan-300"
>
<Type className="h-4 w-4" />
</button>
</Tooltip>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react'
import { LayoutGrid, RotateCcw, Save, Trash2 } from 'lucide-react'
import { Tooltip } from '../../components/ui/tooltip'
import {
type EditorLayout,
type LayoutPreset,
loadLayoutPresets,
saveLayoutPresets,
} from './video-editor-utils'
export interface VideoEditorLayoutMenuProps {
currentLayout: EditorLayout
onApplyLayout: (layout: EditorLayout) => void
onResetLayout: () => void
}
export function VideoEditorLayoutMenu(props: VideoEditorLayoutMenuProps) {
const { currentLayout, onApplyLayout, onResetLayout } = props
const [showLayoutMenu, setShowLayoutMenu] = useState(false)
const [layoutPresets, setLayoutPresets] = useState<LayoutPreset[]>(loadLayoutPresets)
const [savingPresetName, setSavingPresetName] = useState<string | null>(null)
const presetNameInputRef = useRef<HTMLInputElement>(null)
const layoutMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showLayoutMenu) {
setSavingPresetName(null)
return
}
const handleClick = (e: MouseEvent) => {
if (layoutMenuRef.current && !layoutMenuRef.current.contains(e.target as Node)) {
setShowLayoutMenu(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showLayoutMenu])
const handleSaveLayoutPreset = (name: string) => {
const preset: LayoutPreset = {
id: `preset-${Date.now()}`,
name: name.trim() || 'Untitled',
layout: { ...currentLayout },
}
const updated = [...layoutPresets, preset]
setLayoutPresets(updated)
saveLayoutPresets(updated)
}
const handleDeleteLayoutPreset = (id: string) => {
const updated = layoutPresets.filter(p => p.id !== id)
setLayoutPresets(updated)
saveLayoutPresets(updated)
}
const handleApplyLayoutPreset = (preset: LayoutPreset) => {
onApplyLayout({ ...preset.layout })
setShowLayoutMenu(false)
}
return (
<div ref={layoutMenuRef} className="relative">
<button
onClick={() => setShowLayoutMenu(v => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[12px] transition-colors ${
showLayoutMenu ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
}`}
>
<LayoutGrid className="h-3.5 w-3.5" />
Layout
</button>
{showLayoutMenu && (
<div className="absolute top-full right-0 mt-1 w-56 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl shadow-black/50 py-1 z-[60]">
{savingPresetName !== null ? (
<div className="px-2 py-1.5">
<div className="text-[11px] text-zinc-400 mb-1.5 px-1">Name this layout:</div>
<input
ref={presetNameInputRef}
autoFocus
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-[13px] text-white outline-none focus:border-blue-500"
value={savingPresetName}
onChange={e => setSavingPresetName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && savingPresetName.trim()) {
handleSaveLayoutPreset(savingPresetName)
setSavingPresetName(null)
} else if (e.key === 'Escape') {
setSavingPresetName(null)
}
e.stopPropagation()
}}
placeholder="e.g. Wide Timeline"
/>
<div className="flex gap-1.5 mt-1.5">
<button
onClick={() => {
if (savingPresetName.trim()) {
handleSaveLayoutPreset(savingPresetName)
setSavingPresetName(null)
}
}}
disabled={!savingPresetName.trim()}
className="flex-1 px-2 py-1 rounded bg-blue-600 text-white text-[11px] font-medium hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
<button
onClick={() => setSavingPresetName(null)}
className="px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-[11px] hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<>
<button
onClick={() => {
setSavingPresetName('')
requestAnimationFrame(() => presetNameInputRef.current?.focus())
}}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-zinc-200 hover:bg-blue-600 hover:text-white transition-colors"
>
<Save className="h-3.5 w-3.5" />
Save Current Layout...
</button>
<button
onClick={() => {
onResetLayout()
setShowLayoutMenu(false)
}}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-zinc-200 hover:bg-blue-600 hover:text-white transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Reset to Default
</button>
{layoutPresets.length > 0 && (
<>
<div className="h-px bg-zinc-700 my-1 mx-2" />
<div className="px-3 py-1 text-[10px] text-zinc-500 uppercase tracking-wider">Saved Layouts</div>
{layoutPresets.map(preset => (
<div
key={preset.id}
className="flex items-center group hover:bg-blue-600 transition-colors"
>
<button
onClick={() => handleApplyLayoutPreset(preset)}
className="flex-1 flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-zinc-200 group-hover:text-white transition-colors text-left"
>
<LayoutGrid className="h-3.5 w-3.5 text-zinc-500 group-hover:text-white" />
{preset.name}
</button>
<Tooltip content="Delete preset" side="top">
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteLayoutPreset(preset.id)
}}
className="px-2 py-1.5 text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="h-3 w-3" />
</button>
</Tooltip>
</div>
))}
</>
)}
</>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,672 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Play, Pause, Square, SkipBack, SkipForward, ChevronLeft, ChevronRight, Repeat, Video, Music, X } from 'lucide-react'
import type { Asset } from '../../types/project-model'
import { formatTime } from './video-editor-utils'
import { Tooltip } from '../../components/ui/tooltip'
import { pathToFileUrl } from '../../lib/file-url'
import { selectHasSourceAsset } from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
export type SourceKeyboardAction =
| 'transport.playPause'
| 'transport.shuttleReverse'
| 'transport.shuttleStop'
| 'transport.shuttleForward'
| 'transport.stepBackward'
| 'transport.stepForward'
| 'transport.jumpBackward'
| 'transport.jumpForward'
| 'mark.setIn'
| 'mark.setOut'
| 'mark.clearIn'
| 'mark.clearOut'
| 'mark.clearInOut'
| 'edit.insertEdit'
| 'edit.overwriteEdit'
export interface SourceEditRequest {
asset: Asset
sourceIn: number | null
sourceOut: number | null
sourceTime: number
}
export interface VideoEditorSourceMonitorHandle {
openAsset: (asset: Asset, opts?: { initialTime?: number; resetMarks?: boolean }) => void
pause: () => void
dispatchKeyboardAction: (action: SourceKeyboardAction) => void
}
type SourceMarker = 'sourceIn' | 'sourceOut' | null
const FRAME_DURATION = 1 / 24
const LOOP_EPSILON = 0.01
function getLoopRange(sourceIn: number | null, sourceOut: number | null, duration: number) {
const start = sourceIn ?? 0
const end = sourceOut ?? duration
return start <= end
? { start, end }
: { start: end, end: start }
}
export const VideoEditorSourceMonitor = React.forwardRef<VideoEditorSourceMonitorHandle>(function VideoEditorSourceMonitor(_props, ref) {
const {
closeSourceMonitor,
insertSourceEdit,
overwriteSourceEdit,
pause: pauseTimelinePlayback,
setHasSourceAsset,
stopShuttle,
} = useEditorActions()
const hasSourceAsset = useEditorStore(selectHasSourceAsset)
const [sourceAsset, setSourceAsset] = useState<Asset | null>(null)
const [sourceTime, setSourceTime] = useState(0)
const [sourceIsPlaying, setSourceIsPlaying] = useState(false)
const [sourceIn, setSourceIn] = useState<number | null>(null)
const [sourceOut, setSourceOut] = useState<number | null>(null)
const [sourcePlayingInOut, setSourcePlayingInOut] = useState(false)
const [sourceReversePlaying, setSourceReversePlaying] = useState(false)
const [draggingMarker, setDraggingMarker] = useState<SourceMarker>(null)
const sourceVideoRef = useRef<HTMLVideoElement>(null)
const sourceAnimRef = useRef<number>(0)
const reverseRafRef = useRef<number | null>(null)
const reverseLastRef = useRef<number | null>(null)
const pendingSeekRef = useRef<number | null>(null)
const sourceTimeRef = useRef(0)
sourceTimeRef.current = sourceTime
const sourceInRef = useRef<number | null>(null)
sourceInRef.current = sourceIn
const sourceOutRef = useRef<number | null>(null)
sourceOutRef.current = sourceOut
const pause = useCallback(() => {
sourceVideoRef.current?.pause()
setSourceIsPlaying(false)
setSourceReversePlaying(false)
}, [])
const stopPlayback = useCallback(() => {
sourceVideoRef.current?.pause()
setSourceIsPlaying(false)
setSourcePlayingInOut(false)
setSourceReversePlaying(false)
}, [])
const seekTo = useCallback((t: number) => {
setSourceTime(t)
if (sourceVideoRef.current) sourceVideoRef.current.currentTime = t
}, [])
const emitInsertRequest = useCallback(() => {
if (!sourceAsset) return
insertSourceEdit({ asset: sourceAsset, sourceIn, sourceOut, sourceTime })
}, [insertSourceEdit, sourceAsset, sourceIn, sourceOut, sourceTime])
const emitOverwriteRequest = useCallback(() => {
if (!sourceAsset) return
overwriteSourceEdit({ asset: sourceAsset, sourceIn, sourceOut, sourceTime })
}, [overwriteSourceEdit, sourceAsset, sourceIn, sourceOut, sourceTime])
const dispatchKeyboardAction = useCallback((action: SourceKeyboardAction) => {
switch (action) {
case 'transport.playPause':
setSourceReversePlaying(false)
if (sourceIsPlaying) {
pause()
} else {
pauseTimelinePlayback()
stopShuttle()
if (sourceVideoRef.current) {
const duration = sourceAsset?.duration || 5
const { start: loopStart, end: loopEnd } = getLoopRange(sourceIn, sourceOut, duration)
const looping = sourcePlayingInOut
if (looping) {
if (sourceTimeRef.current < loopStart || sourceTimeRef.current >= loopEnd - LOOP_EPSILON) {
sourceVideoRef.current.currentTime = loopStart
setSourceTime(loopStart)
}
} else if (sourceIn !== null && sourceTimeRef.current < sourceIn) {
sourceVideoRef.current.currentTime = sourceIn
setSourceTime(sourceIn)
}
sourceVideoRef.current.play().catch(() => {})
}
setSourceIsPlaying(true)
}
return
case 'transport.shuttleReverse': {
setSourceReversePlaying(false)
const video = sourceVideoRef.current
if (!video) return
video.pause()
setSourceIsPlaying(false)
const t = Math.max(0, video.currentTime - FRAME_DURATION)
video.currentTime = t
setSourceTime(t)
return
}
case 'transport.shuttleStop':
stopPlayback()
return
case 'transport.shuttleForward':
setSourceReversePlaying(false)
pauseTimelinePlayback()
stopShuttle()
if (sourceVideoRef.current) sourceVideoRef.current.play().catch(() => {})
setSourceIsPlaying(true)
return
case 'transport.stepBackward': {
setSourceReversePlaying(false)
const t = Math.max(0, sourceTimeRef.current - FRAME_DURATION)
seekTo(t)
return
}
case 'transport.stepForward': {
setSourceReversePlaying(false)
const dur = sourceAsset?.duration || 5
const t = Math.min(dur, sourceTimeRef.current + FRAME_DURATION)
seekTo(t)
return
}
case 'transport.jumpBackward': {
setSourceReversePlaying(false)
const t = Math.max(0, sourceTimeRef.current - 1)
seekTo(t)
return
}
case 'transport.jumpForward': {
setSourceReversePlaying(false)
const dur = sourceAsset?.duration || 5
const t = Math.min(dur, sourceTimeRef.current + 1)
seekTo(t)
return
}
case 'mark.setIn': {
const st = sourceTimeRef.current
setSourceIn(prev => prev !== null && Math.abs(prev - st) < 0.01 ? null : st)
return
}
case 'mark.setOut': {
const st = sourceTimeRef.current
setSourceOut(prev => prev !== null && Math.abs(prev - st) < 0.01 ? null : st)
return
}
case 'mark.clearIn':
setSourceIn(null)
return
case 'mark.clearOut':
setSourceOut(null)
return
case 'mark.clearInOut':
setSourceIn(null)
setSourceOut(null)
return
case 'edit.insertEdit':
emitInsertRequest()
return
case 'edit.overwriteEdit':
emitOverwriteRequest()
return
default:
return
}
}, [
emitInsertRequest,
emitOverwriteRequest,
pause,
pauseTimelinePlayback,
seekTo,
sourceAsset,
sourceIn,
sourceIsPlaying,
sourceOut,
sourcePlayingInOut,
stopPlayback,
stopShuttle,
])
const openAsset = useCallback((asset: Asset, opts?: { initialTime?: number; resetMarks?: boolean }) => {
const nextTime = Math.max(0, opts?.initialTime ?? 0)
const shouldResetMarks = opts?.resetMarks ?? true
stopPlayback()
setSourceAsset(asset)
setSourceTime(nextTime)
if (shouldResetMarks) {
setSourceIn(null)
setSourceOut(null)
}
pendingSeekRef.current = nextTime
}, [stopPlayback])
React.useImperativeHandle(ref, () => ({
openAsset,
pause,
dispatchKeyboardAction,
}), [dispatchKeyboardAction, openAsset, pause])
useEffect(() => {
const hasAsset = !!sourceAsset
if (hasSourceAsset === hasAsset) return
setHasSourceAsset(hasAsset)
}, [hasSourceAsset, setHasSourceAsset, sourceAsset])
useEffect(() => () => {
setHasSourceAsset(false)
}, [setHasSourceAsset])
useEffect(() => {
if (!sourceAsset || sourceAsset.type !== 'video') return
const pending = pendingSeekRef.current
if (pending === null || !sourceVideoRef.current) return
sourceVideoRef.current.currentTime = pending
pendingSeekRef.current = null
}, [sourceAsset])
useEffect(() => {
if (!sourceIsPlaying || !sourceVideoRef.current) {
cancelAnimationFrame(sourceAnimRef.current)
return
}
const video = sourceVideoRef.current
const duration = sourceAsset?.duration || 5
const { start: loopStart, end: loopEnd } = getLoopRange(sourceIn, sourceOut, duration)
const tick = () => {
setSourceTime(video.currentTime)
if (sourcePlayingInOut && video.currentTime >= loopEnd - LOOP_EPSILON) {
video.currentTime = loopStart
setSourceTime(loopStart)
video.play().catch(() => {})
} else if (!sourcePlayingInOut && sourceOut !== null && video.currentTime >= sourceOut) {
video.pause()
setSourceIsPlaying(false)
setSourceTime(sourceOut)
return
}
if (sourcePlayingInOut || !video.paused) sourceAnimRef.current = requestAnimationFrame(tick)
}
video.play().catch(() => {})
sourceAnimRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(sourceAnimRef.current)
}, [sourceAsset, sourceIn, sourceIsPlaying, sourceOut, sourcePlayingInOut])
useEffect(() => {
if (!sourceReversePlaying) {
if (reverseRafRef.current) cancelAnimationFrame(reverseRafRef.current)
reverseRafRef.current = null
reverseLastRef.current = null
return
}
sourceVideoRef.current?.pause()
const tick = (ts: number) => {
if (!sourceReversePlaying) return
if (reverseLastRef.current !== null) {
const delta = (ts - reverseLastRef.current) / 1000
const duration = sourceAsset?.duration || 5
const { start: loopStart, end: loopEnd } = getLoopRange(sourceInRef.current, sourceOutRef.current, duration)
let next = Math.max(0, (sourceVideoRef.current?.currentTime ?? sourceTimeRef.current) - delta)
if (sourcePlayingInOut && next <= loopStart + LOOP_EPSILON) {
next = loopEnd
}
if (sourceVideoRef.current) sourceVideoRef.current.currentTime = next
setSourceTime(next)
if (!sourcePlayingInOut && next <= 0) {
setSourceReversePlaying(false)
return
}
}
reverseLastRef.current = ts
reverseRafRef.current = requestAnimationFrame(tick)
}
reverseRafRef.current = requestAnimationFrame(tick)
return () => {
if (reverseRafRef.current) cancelAnimationFrame(reverseRafRef.current)
}
}, [sourceAsset, sourcePlayingInOut, sourceReversePlaying])
useEffect(() => {
if (!draggingMarker) return
const handleMouseMove = (e: MouseEvent) => {
const scrubEl = document.getElementById('source-scrub-bar')
if (!scrubEl) return
const rect = scrubEl.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const dur = sourceAsset?.duration || 5
let time = pct * dur
if (draggingMarker === 'sourceIn' && sourceOutRef.current !== null) {
time = Math.min(time, sourceOutRef.current - 0.01)
}
if (draggingMarker === 'sourceOut' && sourceInRef.current !== null) {
time = Math.max(time, sourceInRef.current + 0.01)
}
time = Math.max(0, Math.min(time, dur))
if (draggingMarker === 'sourceIn') {
setSourceIn(time)
} else {
setSourceOut(time)
}
}
const handleMouseUp = () => setDraggingMarker(null)
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [draggingMarker, sourceAsset])
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div className="h-7 bg-zinc-900 border-b border-zinc-800 flex items-center justify-between px-3 flex-shrink-0">
<span className="text-[11px] font-semibold text-zinc-400 tracking-wide">Clip Viewer</span>
<Tooltip content="Close clip viewer" side="left">
<button
onClick={() => {
stopPlayback()
closeSourceMonitor()
}}
className="text-zinc-500 hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
<div className="flex-1 relative overflow-hidden bg-black flex items-center justify-center min-h-0">
{sourceAsset ? (
<>
{sourceAsset.type === 'video' ? (
<video
ref={sourceVideoRef}
src={pathToFileUrl(sourceAsset.path)}
className="max-w-full max-h-full object-contain"
onLoadedMetadata={() => {
const pending = pendingSeekRef.current
if (pending !== null && sourceVideoRef.current) {
sourceVideoRef.current.currentTime = pending
pendingSeekRef.current = null
}
}}
onTimeUpdate={() => {
if (sourceVideoRef.current) setSourceTime(sourceVideoRef.current.currentTime)
}}
onEnded={() => {
if (!sourcePlayingInOut || !sourceVideoRef.current) {
setSourceIsPlaying(false)
return
}
const { start: loopStart } = getLoopRange(sourceIn, sourceOut, sourceAsset.duration || 5)
sourceVideoRef.current.currentTime = loopStart
setSourceTime(loopStart)
sourceVideoRef.current.play().catch(() => {})
setSourceIsPlaying(true)
}}
playsInline
/>
) : sourceAsset.type === 'image' ? (
<img src={pathToFileUrl(sourceAsset.path)} alt="" className="max-w-full max-h-full object-contain" />
) : (
<div className="text-center text-zinc-500">
<Music className="h-12 w-12 mx-auto mb-2" />
<p className="text-sm">{sourceAsset.path?.split('/').pop() || 'Audio'}</p>
</div>
)}
</>
) : (
<div className="text-center text-zinc-600">
<Video className="h-10 w-10 mx-auto mb-2" />
<p className="text-xs">Double-click an asset to load it here</p>
</div>
)}
</div>
{sourceAsset && (sourceAsset.type === 'video' || sourceAsset.type === 'audio') && (
<div className="bg-zinc-900 border-t border-zinc-800 flex-shrink-0 relative px-2 py-1">
<div
id="source-scrub-bar"
className="relative h-6 cursor-pointer group"
onMouseDown={(e) => {
const bar = e.currentTarget
const rect = bar.getBoundingClientRect()
const dur = sourceAsset.duration || 5
const seek = (clientX: number) => {
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const t = frac * dur
seekTo(t)
}
seek(e.clientX)
const onMove = (ev: MouseEvent) => seek(ev.clientX)
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
>
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full" />
{sourceIn !== null && (
<div
className="absolute top-0 bottom-0 left-0 bg-black/50 rounded-l"
style={{ width: `${(sourceIn / (sourceAsset.duration || 5)) * 100}%` }}
/>
)}
{sourceOut !== null && (
<div
className="absolute top-0 bottom-0 right-0 bg-black/50 rounded-r"
style={{ width: `${100 - (sourceOut / (sourceAsset.duration || 5)) * 100}%` }}
/>
)}
{(sourceIn !== null || sourceOut !== null) && (
<div
className="absolute top-0 bottom-0 border-t-2 border-b-2 border-blue-400/70"
style={{
left: `${((sourceIn ?? 0) / (sourceAsset.duration || 5)) * 100}%`,
width: `${(((sourceOut ?? sourceAsset.duration ?? 5) - (sourceIn ?? 0)) / (sourceAsset.duration || 5)) * 100}%`,
}}
>
<div className="absolute inset-0 top-1/2 -translate-y-1/2 h-1 bg-blue-400/40 rounded-full" />
</div>
)}
{sourceIn !== null && (
<div
className="absolute top-0 bottom-0 flex items-center z-10 cursor-ew-resize"
style={{ left: `calc(${(sourceIn / (sourceAsset.duration || 5)) * 100}% - 8px)`, width: 14 }}
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); setDraggingMarker('sourceIn') }}
>
<div className="w-1.5 h-full bg-blue-400 rounded-l-sm flex flex-col justify-between py-0.5 pointer-events-none ml-auto">
<div className="w-2.5 h-0.5 bg-blue-400 rounded-r" />
<div className="w-2.5 h-0.5 bg-blue-400 rounded-r" />
</div>
</div>
)}
{sourceOut !== null && (
<div
className="absolute top-0 bottom-0 flex items-center z-10 cursor-ew-resize"
style={{ left: `${(sourceOut / (sourceAsset.duration || 5)) * 100}%`, width: 14 }}
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); setDraggingMarker('sourceOut') }}
>
<div className="w-1.5 h-full bg-blue-400 rounded-r-sm flex flex-col justify-between py-0.5 pointer-events-none">
<div className="w-2.5 h-0.5 bg-blue-400 rounded-l -ml-1" />
<div className="w-2.5 h-0.5 bg-blue-400 rounded-l -ml-1" />
</div>
</div>
)}
<div
className="absolute top-0 bottom-0 z-20"
style={{ left: `${(sourceTime / (sourceAsset.duration || 5)) * 100}%` }}
>
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-2 h-2 bg-blue-400 clip-triangle" style={{ clipPath: 'polygon(50% 100%, 0% 0%, 100% 0%)' }} />
<div className="absolute top-2 bottom-0 left-1/2 -translate-x-1/2 w-px bg-blue-400" />
</div>
</div>
{(sourceIn !== null || sourceOut !== null) && (
<div className="flex justify-between items-center mt-0.5 h-3.5">
<span className="text-[9px] font-mono text-blue-400/80">
{sourceIn !== null ? `IN ${formatTime(sourceIn)}` : ''}
</span>
<span className="text-[9px] font-mono text-zinc-500">
{sourceIn !== null && sourceOut !== null
? `Duration: ${formatTime(sourceOut - sourceIn)}`
: ''}
</span>
<span className="text-[9px] font-mono text-blue-400/80">
{sourceOut !== null ? `OUT ${formatTime(sourceOut)}` : ''}
</span>
</div>
)}
</div>
)}
<div className="h-8 bg-zinc-950 border-t border-zinc-800 flex items-center px-3 flex-shrink-0 gap-2">
<span className="text-[12px] font-mono font-medium text-amber-400 tabular-nums tracking-tight select-none min-w-[90px]">
{formatTime(sourceTime)}
</span>
<div className="flex-1 flex items-center justify-center gap-0.5">
<Tooltip content={sourceIn !== null ? `In: ${formatTime(sourceIn)}` : 'Set In (I)'} side="top">
<button
onClick={() => setSourceIn(prev => prev !== null && Math.abs(prev - sourceTime) < 0.01 ? null : sourceTime)}
className={`h-6 w-6 flex items-center justify-center rounded transition-colors ${sourceIn !== null ? 'text-yellow-400' : 'text-zinc-500 hover:text-white hover:bg-zinc-800'}`}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7,4 4,4 4,20 7,20" />
<line x1="10" y1="12" x2="20" y2="12" />
<polyline points="16,8 20,12 16,16" />
</svg>
</button>
</Tooltip>
<div className="w-px h-3 bg-zinc-700" />
<Tooltip content="Go to start" side="top">
<button
onClick={() => seekTo(sourceIn ?? 0)}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<SkipBack className="h-3 w-3" />
</button>
</Tooltip>
<Tooltip content="Step back" side="top">
<button
onClick={() => dispatchKeyboardAction('transport.stepBackward')}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</Tooltip>
<Tooltip content="Play reverse" side="top">
<button
onClick={() => {
if (sourceReversePlaying) {
setSourceReversePlaying(false)
} else {
sourceVideoRef.current?.pause()
setSourceIsPlaying(false)
setSourceReversePlaying(true)
}
}}
className={`h-6 w-6 flex items-center justify-center rounded transition-colors ${sourceReversePlaying ? 'text-blue-400' : 'text-zinc-500 hover:text-white hover:bg-zinc-800'}`}
>
<Play className="h-3 w-3 mr-0.5 rotate-180" />
</button>
</Tooltip>
<Tooltip content="Stop" side="top">
<button
onClick={() => dispatchKeyboardAction('transport.shuttleStop')}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<Square className="h-2.5 w-2.5" />
</button>
</Tooltip>
<Tooltip content={sourceIsPlaying ? 'Pause' : 'Play'} side="top">
<button
onClick={() => dispatchKeyboardAction('transport.playPause')}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
{sourceIsPlaying ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3 ml-0.5" />}
</button>
</Tooltip>
<Tooltip content="Step forward" side="top">
<button
onClick={() => dispatchKeyboardAction('transport.stepForward')}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</Tooltip>
<Tooltip content="Go to end" side="top">
<button
onClick={() => seekTo(sourceOut ?? (sourceAsset?.duration || 5))}
className="h-6 w-6 flex items-center justify-center rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<SkipForward className="h-3 w-3" />
</button>
</Tooltip>
<div className="w-px h-3 bg-zinc-700" />
<Tooltip content={sourceOut !== null ? `Out: ${formatTime(sourceOut)}` : 'Set Out (O)'} side="top">
<button
onClick={() => setSourceOut(prev => prev !== null && Math.abs(prev - sourceTime) < 0.01 ? null : sourceTime)}
className={`h-6 w-6 flex items-center justify-center rounded transition-colors ${sourceOut !== null ? 'text-yellow-400' : 'text-zinc-500 hover:text-white hover:bg-zinc-800'}`}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="17,4 20,4 20,20 17,20" />
<line x1="14" y1="12" x2="4" y2="12" />
<polyline points="8,8 4,12 8,16" />
</svg>
</button>
</Tooltip>
<Tooltip content="Loop In/Out" side="top">
<button
onClick={() => {
if (sourcePlayingInOut) {
stopPlayback()
return
}
const loopStart = sourceIn ?? 0
pauseTimelinePlayback()
stopShuttle()
setSourceReversePlaying(false)
setSourcePlayingInOut(true)
seekTo(loopStart)
if (sourceVideoRef.current) {
sourceVideoRef.current.currentTime = loopStart
sourceVideoRef.current.play().catch(() => {})
}
setSourceIsPlaying(true)
}}
className={`h-6 w-6 flex items-center justify-center rounded transition-colors ${
sourcePlayingInOut ? 'text-yellow-400 bg-yellow-400/10' : 'text-zinc-500 hover:text-white hover:bg-zinc-800'
}`}
>
<Repeat className="h-3 w-3" />
</button>
</Tooltip>
<div className="w-px h-3 bg-zinc-700 mx-0.5" />
<Tooltip content="Insert Edit (,)" side="top">
<button
onClick={emitInsertRequest}
disabled={!sourceAsset}
className="h-6 px-1 flex items-center rounded text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
</Tooltip>
<Tooltip content="Overwrite Edit (.)" side="top">
<button
onClick={emitOverwriteRequest}
disabled={!sourceAsset}
className="h-6 px-1 flex items-center rounded text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12h6" /></svg>
</button>
</Tooltip>
</div>
<span className="text-[12px] font-mono font-medium text-zinc-400 tabular-nums tracking-tight select-none min-w-[90px] text-right">
{formatTime(sourceAsset?.duration || 0)}
</span>
</div>
</div>
)
})
@@ -0,0 +1,215 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, FileUp, Film, Trash2 } from 'lucide-react'
import { Tooltip } from '../../components/ui/tooltip'
import { shallow } from 'zustand/vanilla/shallow'
import {
selectActiveTimelineId,
selectOpenTimelineIds,
selectTimelineRenameState,
selectTimelines,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
export interface VideoEditorTimelineControlPanelProps {
handleTimelineTabContextMenu: (e: React.MouseEvent, timelineId: string) => void
}
export function VideoEditorTimelineControlPanel(props: VideoEditorTimelineControlPanelProps) {
const {
handleTimelineTabContextMenu,
} = props
const {
cancelTimelineRename,
commitTimelineRename,
createTimeline,
deleteTimeline,
openImportTimelineModal,
setTimelineRenameValue,
startTimelineRename,
switchActiveTimeline,
} = useEditorActions()
const timelines = useEditorStore(selectTimelines)
const activeTimelineId = useEditorStore(selectActiveTimelineId)
const openTimelineIds = useEditorStore(selectOpenTimelineIds)
const { renamingTimelineId, renameValue, renameSource } = useEditorStore(selectTimelineRenameState, shallow)
const timelineItems = useMemo(() => (
timelines.map(timeline => ({
timeline,
isActive: timeline.id === activeTimelineId,
isOpen: openTimelineIds.has(timeline.id),
isRenaming: renamingTimelineId === timeline.id,
clipCount: timeline.clips.length,
duration: timeline.clips.reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0),
}))
), [activeTimelineId, openTimelineIds, renamingTimelineId, timelines])
const [timelineAddMenuOpen, setTimelineAddMenuOpen] = useState(false)
const addMenuRef = useRef<HTMLDivElement>(null)
const setRenameValue = (value: string) => {
setTimelineRenameValue(value)
}
const setRenamingTimelineId = (value: string | null) => {
if (value === null) cancelTimelineRename()
}
const handleAddTimeline = () => {
createTimeline()
}
const handleSwitchTimeline = (timelineId: string) => {
switchActiveTimeline(timelineId)
}
const handleDeleteTimeline = (timelineId: string) => {
deleteTimeline(timelineId)
}
const handleStartRename = (timelineId: string, currentName: string, source: 'tab' | 'panel' = 'panel') => {
void currentName
startTimelineRename(timelineId, source)
}
const handleFinishRename = () => {
commitTimelineRename()
}
useEffect(() => {
if (!timelineAddMenuOpen) return
const onClickAway = (e: MouseEvent) => {
if (!addMenuRef.current?.contains(e.target as Node)) {
setTimelineAddMenuOpen(false)
}
}
const timer = setTimeout(() => window.addEventListener('click', onClickAway), 0)
return () => {
clearTimeout(timer)
window.removeEventListener('click', onClickAway)
}
}, [timelineAddMenuOpen])
return (
<div className="flex flex-col min-h-0 h-full">
<div className="p-3 pb-2 flex items-center justify-between flex-shrink-0">
<h3 className="text-sm font-semibold text-white">Timelines</h3>
<div className="relative" ref={addMenuRef}>
<Tooltip content="Add timeline" side="right">
<button
onClick={() => setTimelineAddMenuOpen(prev => !prev)}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</Tooltip>
{timelineAddMenuOpen && (
<div className="absolute right-0 top-full mt-1 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 overflow-hidden">
<button
onClick={() => { handleAddTimeline(); setTimelineAddMenuOpen(false) }}
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
>
<Plus className="h-3.5 w-3.5" />
New Timeline
</button>
<button
onClick={() => {
openImportTimelineModal()
setTimelineAddMenuOpen(false)
}}
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
>
<FileUp className="h-3.5 w-3.5" />
Import from XML
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-auto px-3 pb-3 space-y-1">
{timelineItems.map(item => {
const tl = item.timeline
const isActive = item.isActive
const clipCount = item.clipCount
const tlDuration = item.duration
const formatDur = (s: number) => {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
}
return (
<div
key={tl.id}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
isActive
? 'bg-blue-600/20 border border-blue-500/40'
: 'hover:bg-zinc-800 border border-transparent'
}`}
draggable={!isActive}
onDragStart={(e) => {
if (isActive) { e.preventDefault(); return }
e.dataTransfer.setData('timeline', JSON.stringify({ id: tl.id, name: tl.name }))
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => handleSwitchTimeline(tl.id)}
onDoubleClick={() => handleStartRename(tl.id, tl.name, 'panel')}
onContextMenu={(e) => handleTimelineTabContextMenu(e, tl.id)}
>
<Film className={`h-4 w-4 flex-shrink-0 ${isActive ? 'text-blue-400' : 'text-zinc-500'}`} />
<div className="flex-1 min-w-0">
{item.isRenaming && renameSource === 'panel' ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') { setRenamingTimelineId(null); setRenameValue('') }
}}
className="bg-zinc-900 border border-blue-500 rounded px-1 py-0.5 outline-none text-white text-xs w-full"
autoFocus
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
/>
) : (
<p className={`text-xs font-medium truncate ${isActive ? 'text-white' : 'text-zinc-300'}`}>
{tl.name}
</p>
)}
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
<span>{clipCount} clip{clipCount !== 1 ? 's' : ''}</span>
{clipCount > 0 && (
<>
<span>·</span>
<span>{formatDur(tlDuration)}</span>
</>
)}
</div>
</div>
{isActive ? (
<span className="text-[9px] text-blue-400 font-medium uppercase tracking-wider flex-shrink-0">Active</span>
) : item.isOpen ? (
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500 flex-shrink-0" title="Open in tabs" />
) : null}
{timelineItems.length > 1 && (
<Tooltip content="Delete timeline" side="right">
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteTimeline(tl.id)
}}
className="p-1 rounded hover:bg-red-500/20 text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</button>
</Tooltip>
)}
</div>
)
})}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
export function VideoThumbnailCard({ videoUrl, thumbnailUrl }: { videoUrl: string; thumbnailUrl?: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isHovering, setIsHovering] = useState(false)
const [videoReady, setVideoReady] = useState(false)
const [scrubProgress, setScrubProgress] = useState(0)
const [scrubTime, setScrubTime] = useState('')
const rafRef = useRef<number>(0)
const drawFrame = useCallback(() => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas || video.readyState < 2) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const rect = canvas.getBoundingClientRect()
if (canvas.width !== rect.width || canvas.height !== rect.height) {
canvas.width = rect.width
canvas.height = rect.height
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const video = videoRef.current
const container = containerRef.current
if (!video || !container || !video.duration || isNaN(video.duration)) return
const rect = container.getBoundingClientRect()
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width))
const pct = x / rect.width
const targetTime = pct * video.duration
video.currentTime = targetTime
setScrubProgress(pct)
const mins = Math.floor(targetTime / 60)
const secs = Math.floor(targetTime % 60)
const frames = Math.floor((targetTime % 1) * 24)
setScrubTime(`${mins}:${secs.toString().padStart(2, '0')}:${frames.toString().padStart(2, '0')}`)
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(drawFrame)
}, [drawFrame])
const handleMouseEnter = useCallback(() => {
setIsHovering(true)
}, [])
const handleMouseLeave = useCallback(() => {
setIsHovering(false)
setVideoReady(false)
setScrubProgress(0)
setScrubTime('')
}, [])
useEffect(() => {
if (!isHovering) return
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
video.src = videoUrl
videoRef.current = video
const onLoaded = () => {
setVideoReady(true)
requestAnimationFrame(drawFrame)
}
const onSeeked = () => requestAnimationFrame(drawFrame)
video.addEventListener('loadeddata', onLoaded)
video.addEventListener('seeked', onSeeked)
video.load()
return () => {
video.removeEventListener('loadeddata', onLoaded)
video.removeEventListener('seeked', onSeeked)
video.pause()
video.removeAttribute('src')
video.load()
if (videoRef.current === video) {
videoRef.current = null
}
}
}, [isHovering, videoUrl, drawFrame])
return (
<div
ref={containerRef}
className="w-full aspect-video relative overflow-hidden bg-zinc-900"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={isHovering ? handleMouseMove : undefined}
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className={`w-full h-full object-cover absolute inset-0 ${isHovering && videoReady ? 'opacity-0' : 'opacity-100'} transition-opacity duration-100`}
/>
) : (
<div className={`w-full h-full absolute inset-0 ${isHovering && videoReady ? 'opacity-0' : 'opacity-100'} transition-opacity duration-100`} />
)}
<canvas
ref={canvasRef}
className={`w-full h-full object-cover absolute inset-0 ${isHovering && videoReady ? 'opacity-100' : 'opacity-0'} transition-opacity duration-100`}
/>
{isHovering && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-black/40">
<div
className="h-full bg-blue-500 transition-none"
style={{ width: `${scrubProgress * 100}%` }}
/>
</div>
)}
{isHovering && videoReady && scrubTime && (
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 backdrop-blur-sm">
<span className="text-[9px] text-white font-mono tabular-nums">{scrubTime}</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,269 @@
import { type MenuDefinition } from '../../components/MenuBar'
import { TEXT_PRESETS } from '../../types/project'
import type { TimelineClip } from '../../types/project-model'
import type { KeyboardLayout } from '../../lib/keyboard-shortcuts'
import { useMemo } from 'react'
import { shallow } from 'zustand/vanilla/shallow'
import {
selectCanInsertEdit,
selectCanRedo,
selectCanUndo,
selectCanOverwriteEdit,
selectCanUseClipboard,
selectCurrentTime,
selectMenuState,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
import { getShortcutLabel } from './video-editor-utils'
export interface MenuDepsParams {
kbLayout: KeyboardLayout
fileInputRef: React.RefObject<HTMLInputElement>
subtitleFileInputRef: React.RefObject<HTMLInputElement>
handleExportTimelineXml: () => void
handleExportSrt: () => void
handleInsertEdit: () => void
handleOverwriteEdit: () => void
handleMatchFrame: () => void
setKbEditorOpen: (v: boolean) => void
fitToViewRef: React.RefObject<() => void>
handleResetLayout: () => void
canUseIcLora: boolean
onICLoraClip: (clip: TimelineClip) => void
}
export function useBuildMenuDefinitions(p: MenuDepsParams): MenuDefinition[] {
const actions = useEditorActions()
const menuState = useEditorStore(selectMenuState, shallow)
const canUseClipboard = useEditorStore(selectCanUseClipboard)
const canUndo = useEditorStore(selectCanUndo)
const canRedo = useEditorStore(selectCanRedo)
const canInsertEdit = useEditorStore(selectCanInsertEdit)
const canOverwriteEdit = useEditorStore(selectCanOverwriteEdit)
const currentTime = useEditorStore(selectCurrentTime)
return useMemo(() => ([
{
id: 'file',
label: 'File',
items: [
{ id: 'new-timeline', label: 'New Timeline', action: () => actions.createTimeline() },
{
id: 'duplicate-timeline',
label: 'Duplicate Active Timeline',
action: () => {
if (menuState.activeTimeline) {
actions.duplicateTimeline(menuState.activeTimeline.id)
}
},
disabled: !menuState.activeTimeline,
},
{ id: 'sep-0', label: '', separator: true },
{ id: 'import-media', label: 'Import Media...', shortcut: 'Ctrl+I', action: () => p.fileInputRef.current?.click() },
{ id: 'import-timeline', label: 'Import Timeline (XML)...', action: () => actions.openImportTimelineModal() },
{ id: 'import-srt', label: 'Import Subtitles (SRT)...', action: () => p.subtitleFileInputRef.current?.click() },
{ id: 'sep-1', label: '', separator: true },
{ id: 'export-timeline', label: 'Export Timeline...', shortcut: 'Ctrl+E', action: () => actions.openExportModal() },
{ id: 'export-xml', label: 'Export FCP7 XML...', action: () => p.handleExportTimelineXml() },
{ id: 'export-srt', label: 'Export Subtitles (SRT)...', action: () => p.handleExportSrt(), disabled: menuState.subtitles.length === 0 },
],
},
{
id: 'edit',
label: 'Edit',
items: [
{ id: 'undo', label: 'Undo', shortcut: getShortcutLabel(p.kbLayout, 'edit.undo'), action: () => actions.undo(), disabled: !canUndo },
{ id: 'redo', label: 'Redo', shortcut: getShortcutLabel(p.kbLayout, 'edit.redo'), action: () => actions.redo(), disabled: !canRedo },
{ id: 'sep-1', label: '', separator: true },
{ id: 'cut', label: 'Cut', shortcut: getShortcutLabel(p.kbLayout, 'edit.cut'), action: () => actions.cutSelection() },
{ id: 'copy', label: 'Copy', shortcut: getShortcutLabel(p.kbLayout, 'edit.copy'), action: () => actions.copySelection() },
{ id: 'paste', label: 'Paste', shortcut: getShortcutLabel(p.kbLayout, 'edit.paste'), action: () => actions.pasteSelection(), disabled: !canUseClipboard },
{ id: 'sep-2', label: '', separator: true },
{ id: 'select-all', label: 'Select All', shortcut: getShortcutLabel(p.kbLayout, 'edit.selectAll'), action: () => actions.selectAllClips() },
{ id: 'deselect-all', label: 'Deselect All', shortcut: getShortcutLabel(p.kbLayout, 'edit.deselect'), action: () => actions.clearClipSelection() },
{ id: 'sep-3', label: '', separator: true },
{ id: 'insert-edit', label: 'Insert Edit', shortcut: getShortcutLabel(p.kbLayout, 'edit.insertEdit'), action: () => p.handleInsertEdit(), disabled: !canInsertEdit },
{ id: 'overwrite-edit', label: 'Overwrite Edit', shortcut: getShortcutLabel(p.kbLayout, 'edit.overwriteEdit'), action: () => p.handleOverwriteEdit(), disabled: !canOverwriteEdit },
{ id: 'match-frame', label: 'Match Frame', shortcut: getShortcutLabel(p.kbLayout, 'edit.matchFrame'), action: () => p.handleMatchFrame() },
{ id: 'sep-4', label: '', separator: true },
{ id: 'keyboard-shortcuts', label: 'Keyboard Shortcuts...', action: () => p.setKbEditorOpen(true) },
],
},
{
id: 'clip',
label: 'Clip',
items: [
{
id: 'split',
label: 'Split at Playhead',
shortcut: getShortcutLabel(p.kbLayout, 'tool.blade'),
action: () => {
if (menuState.selectedClip) {
actions.splitClipsAtTime([menuState.selectedClip.id], currentTime)
}
},
disabled: !menuState.selectedClip,
},
{
id: 'duplicate',
label: 'Duplicate Clip',
action: () => {
if (menuState.selectedClip) {
actions.duplicateClips([menuState.selectedClip.id])
}
},
disabled: !menuState.selectedClip,
},
{
id: 'delete',
label: 'Delete',
shortcut: getShortcutLabel(p.kbLayout, 'edit.delete'),
action: () => actions.deleteClips([...menuState.selectedClipIds]),
disabled: menuState.selectedClipIds.size === 0,
},
{ id: 'sep-1', label: '', separator: true },
{
id: 'flip-h',
label: 'Flip Horizontal',
action: () => {
if (menuState.selectedClip) {
actions.updateClip(menuState.selectedClip.id, { flipH: !menuState.selectedClip.flipH })
}
},
disabled: !menuState.selectedClip,
},
{
id: 'flip-v',
label: 'Flip Vertical',
action: () => {
if (menuState.selectedClip) {
actions.updateClip(menuState.selectedClip.id, { flipV: !menuState.selectedClip.flipV })
}
},
disabled: !menuState.selectedClip,
},
{
id: 'reverse',
label: 'Reverse',
action: () => {
if (menuState.selectedClip) {
actions.toggleClipReverse(menuState.selectedClip.id)
}
},
disabled: !menuState.selectedClip,
},
{ id: 'sep-2', label: '', separator: true },
{
id: 'mute',
label: menuState.selectedClip?.muted ? 'Unmute Clip' : 'Mute Clip',
action: () => {
if (menuState.selectedClip) {
actions.toggleClipMute(menuState.selectedClip.id)
}
},
disabled: !menuState.selectedClip,
},
{
id: 'link-audio',
label: menuState.selectedClip?.linkedClipIds?.length ? 'Unlink Audio' : 'Link Audio',
action: () => {
if (menuState.selectedClip?.linkedClipIds?.length) {
actions.unlinkClipGroup(menuState.selectedClip.id)
}
},
disabled: !menuState.selectedClip,
},
{ id: 'sep-3', label: '', separator: true },
{ id: 'speed-025', label: 'Speed: 0.25x', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 0.25 }), disabled: !menuState.selectedClip },
{ id: 'speed-050', label: 'Speed: 0.5x', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 0.5 }), disabled: !menuState.selectedClip },
{ id: 'speed-100', label: 'Speed: 1x (Normal)', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 1 }), disabled: !menuState.selectedClip },
{ id: 'speed-150', label: 'Speed: 1.5x', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 1.5 }), disabled: !menuState.selectedClip },
{ id: 'speed-200', label: 'Speed: 2x', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 2 }), disabled: !menuState.selectedClip },
{ id: 'speed-400', label: 'Speed: 4x', action: () => menuState.selectedClip && actions.updateClip(menuState.selectedClip.id, { speed: 4 }), disabled: !menuState.selectedClip },
],
},
{
id: 'sequence',
label: 'Sequence',
items: [
{ id: 'add-video-track', label: 'Add Video Track', action: () => actions.addTrack('video') },
{ id: 'add-audio-track', label: 'Add Audio Track', action: () => actions.addTrack('audio') },
{ id: 'add-subtitle-track', label: 'Add Subtitle Track', action: () => actions.addSubtitleTrack() },
{ id: 'sep-1', label: '', separator: true },
{ id: 'add-adjustment', label: 'Add Adjustment Layer', action: () => actions.createAdjustmentLayerAsset() },
{ id: 'sep-2', label: '', separator: true },
{ id: 'add-text', label: 'Add Text Overlay', action: () => actions.addTextClip() },
{ id: 'add-text-lower', label: 'Add Lower Third', action: () => actions.addTextClip({ style: TEXT_PRESETS.find(pr => pr.id === 'lower-third-basic')?.style }) },
{ id: 'add-text-subtitle', label: 'Add Caption', action: () => actions.addTextClip({ style: TEXT_PRESETS.find(pr => pr.id === 'subtitle-style')?.style }) },
{ id: 'sep-3', label: '', separator: true },
{ id: 'snap-toggle', label: menuState.snapEnabled ? 'Disable Snapping' : 'Enable Snapping', shortcut: getShortcutLabel(p.kbLayout, 'timeline.toggleSnap'), action: () => actions.toggleSnap() },
],
},
{
id: 'tools',
label: 'Tools',
items: [
{ id: 'tool-select', label: 'Selection Tool', shortcut: getShortcutLabel(p.kbLayout, 'tool.select'), action: () => actions.setActiveTool('select') },
{ id: 'tool-blade', label: 'Blade Tool', shortcut: getShortcutLabel(p.kbLayout, 'tool.blade'), action: () => actions.setActiveTool('blade') },
{ id: 'sep-1', label: '', separator: true },
{ id: 'tool-ripple', label: 'Ripple Trim', shortcut: getShortcutLabel(p.kbLayout, 'tool.ripple'), action: () => { actions.setActiveTool('ripple'); actions.setLastTrimTool('ripple') } },
{ id: 'tool-roll', label: 'Roll Trim', shortcut: getShortcutLabel(p.kbLayout, 'tool.roll'), action: () => { actions.setActiveTool('roll'); actions.setLastTrimTool('roll') } },
{ id: 'tool-slip', label: 'Slip Tool', shortcut: getShortcutLabel(p.kbLayout, 'tool.slip'), action: () => { actions.setActiveTool('slip'); actions.setLastTrimTool('slip') } },
{ id: 'tool-slide', label: 'Slide Tool', shortcut: getShortcutLabel(p.kbLayout, 'tool.slide'), action: () => { actions.setActiveTool('slide'); actions.setLastTrimTool('slide') } },
{ id: 'sep-2', label: '', separator: true },
...(p.canUseIcLora ? [{
id: 'ic-lora',
label: 'IC-LoRA Style Transfer...',
action: () => {
if (menuState.selectedClip?.type === 'video') {
p.onICLoraClip(menuState.selectedClip)
}
},
disabled: menuState.selectedClip?.type !== 'video',
}] : []),
],
},
{
id: 'view',
label: 'View',
items: [
{ id: 'clip-viewer', label: menuState.showSourceMonitor ? 'Hide Clip Viewer' : 'Show Clip Viewer', action: () => actions.setShowSourceMonitor(!menuState.showSourceMonitor) },
{ id: 'properties-panel', label: menuState.showPropertiesPanel ? 'Hide Properties Panel' : 'Show Properties Panel', action: () => actions.setShowPropertiesPanel(!menuState.showPropertiesPanel) },
{ id: 'sep-1', label: '', separator: true },
{ id: 'fit-to-view', label: 'Zoom to Fit', shortcut: getShortcutLabel(p.kbLayout, 'timeline.fitToView'), action: () => p.fitToViewRef.current?.() },
{ id: 'zoom-in', label: 'Zoom In', shortcut: getShortcutLabel(p.kbLayout, 'timeline.zoomIn'), action: () => actions.zoomIn() },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: getShortcutLabel(p.kbLayout, 'timeline.zoomOut'), action: () => actions.zoomOut() },
{ id: 'sep-2', label: '', separator: true },
{ id: 'reset-layout', label: 'Reset Layout', action: () => p.handleResetLayout() },
],
},
{
id: 'help',
label: 'Help',
items: [
{ id: 'shortcuts', label: 'Keyboard Shortcuts...', action: () => p.setKbEditorOpen(true) },
{ id: 'about', label: 'About LTX Desktop', action: () => window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'about' } })) },
],
},
]), [
actions,
canInsertEdit,
canOverwriteEdit,
canUseClipboard,
currentTime,
menuState,
p.canUseIcLora,
p.fileInputRef,
p.fitToViewRef,
p.handleExportSrt,
p.handleExportTimelineXml,
p.handleInsertEdit,
p.handleMatchFrame,
p.handleOverwriteEdit,
p.handleResetLayout,
p.kbLayout,
p.onICLoraClip,
p.setKbEditorOpen,
p.subtitleFileInputRef,
])
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import type { Project, Timeline } from '../../types/project-model'
import type { EditorModel } from './editor-state'
import { DEFAULT_TRACKS } from '../../types/project-model'
import { migrateClip, migrateTracks } from './video-editor-utils'
function normalizeTimeline(timeline: Timeline): Timeline {
const sourceTracks = timeline.tracks.length > 0
? timeline.tracks
: DEFAULT_TRACKS.map(track => ({ ...track }))
return {
...timeline,
tracks: migrateTracks(sourceTracks),
clips: timeline.clips.map(migrateClip),
subtitles: timeline.subtitles || [],
}
}
export function getEditorModel(project: Project): EditorModel {
const timelines = project.timelines.map(normalizeTimeline)
return {
assets: project.assets,
bins: project.bins,
timelines,
activeTimelineId: project.activeTimelineId ?? timelines[0]?.id ?? null,
}
}
export function updatedProject(fromProject: Project, editorModel: EditorModel): Project {
return {
...fromProject,
assets: editorModel.assets,
bins: editorModel.bins,
timelines: editorModel.timelines,
activeTimelineId: editorModel.activeTimelineId ?? editorModel.timelines[0]?.id,
updatedAt: Date.now(),
}
}
+940
View File
@@ -0,0 +1,940 @@
import {
COLOR_LABELS,
CUT_POINT_TOLERANCE,
type ToolType,
} from './video-editor-utils'
import {
DEFAULT_SUBTITLE_STYLE,
DEFAULT_TRACKS,
} from '../../types/project-model'
import type {
Asset,
SubtitleClip,
SubtitleStyle,
Timeline,
TimelineClip,
Track,
} from '../../types/project-model'
import {
namedResolutionDisplayName,
namedResolutionTier,
} from '../../lib/video-resolution'
import type {
AssetListFilters,
AssetTakeView,
ClipCapabilities,
ClipDimensions,
ClipMetadata,
ClipResolutionInfo,
EditorModel,
EditorState,
KeyboardCommandContext,
MenuState,
OrderedTrackEntry,
SelectedClipPropertiesModel,
SelectedSubtitleEditorModel,
SubtitleTrackStyleEditorModel,
TimelineCutPoint,
TimelineGapSelection,
TimelineInOutRange,
TimelineListItem,
} from './editor-state'
export interface ExportLetterbox {
ratio: number
color: string
opacity: number
}
export interface ExportClipData {
path: string
type: string
startTime: number
duration: number
trimStart: number
speed: number
reversed: boolean
flipH: boolean
flipV: boolean
opacity: number
trackIndex: number
muted: boolean
volume: number
}
export interface ExportSubtitleData {
text: string
startTime: number
endTime: number
style: SubtitleStyle
}
export interface ExportModalModel {
timeline: Timeline | null
clips: TimelineClip[]
tracks: Track[]
exportClips: ExportClipData[]
subtitleData: ExportSubtitleData[]
letterbox: ExportLetterbox | null
}
export interface ClipAudioControlsModel {
targetClipId: string
muted: boolean
volume: number
}
const EMPTY_CLIPS: TimelineClip[] = []
const EMPTY_SUBTITLES: SubtitleClip[] = []
const DEFAULT_TIMELINE_TRACKS: Track[] = DEFAULT_TRACKS.map(track => ({ ...track }))
const EMPTY_TIMELINE_IN_OUT_RANGE: TimelineInOutRange = { inPoint: null, outPoint: null }
const LETTERBOX_RATIO_MAP: Record<string, number> = {
'2.35:1': 2.35,
'2.39:1': 2.39,
'2.76:1': 2.76,
'1.85:1': 1.85,
'4:3': 4 / 3,
}
function parseResolutionHeight(resolution?: string): number {
const match = resolution?.match(/(\d+)/)
return match ? parseInt(match[1], 10) : 0
}
export function getActiveTimelineFromEditorModel(editorModel: EditorModel): Timeline | null {
if (editorModel.timelines.length === 0) return null
return editorModel.timelines.find(timeline => timeline.id === editorModel.activeTimelineId)
|| editorModel.timelines[0]
}
export function selectEditorModel(state: EditorState): EditorModel {
return state.editorModel
}
export function selectSession(state: EditorState): EditorState['session'] {
return state.session
}
export function selectHistory(state: EditorState): EditorState['history'] {
return state.history
}
export function selectCanUndo(state: EditorState): boolean {
return state.history.undoStack.length > 0
}
export function selectCanRedo(state: EditorState): boolean {
return state.history.redoStack.length > 0
}
export function selectAssets(state: EditorState): Asset[] {
return state.editorModel.assets
}
export function selectTimelines(state: EditorState): Timeline[] {
return state.editorModel.timelines
}
export function selectActiveTimelineId(state: EditorState): string | null {
return state.editorModel.activeTimelineId
}
export function selectActiveTimeline(state: EditorState): Timeline | null {
return getActiveTimelineFromEditorModel(state.editorModel)
}
export function selectTimelineById(state: EditorState, timelineId: string): Timeline | undefined {
return state.editorModel.timelines.find(timeline => timeline.id === timelineId)
}
export function selectClipById(state: EditorState, clipId: string | null | undefined): TimelineClip | undefined {
if (!clipId) return undefined
return selectClips(state).find(clip => clip.id === clipId)
}
export function selectClips(state: EditorState): TimelineClip[] {
return selectActiveTimeline(state)?.clips || EMPTY_CLIPS
}
export function selectTracks(state: EditorState): Track[] {
return selectActiveTimeline(state)?.tracks || DEFAULT_TIMELINE_TRACKS
}
export function selectSubtitles(state: EditorState): SubtitleClip[] {
return selectActiveTimeline(state)?.subtitles || EMPTY_SUBTITLES
}
export function selectActiveTimelineClips(state: EditorState, timelineId?: string): TimelineClip[] {
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
return timeline?.clips || EMPTY_CLIPS
}
export function selectActiveTimelineTracks(state: EditorState, timelineId?: string): Track[] {
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
return timeline?.tracks || DEFAULT_TIMELINE_TRACKS
}
export function selectActiveTimelineSubtitles(state: EditorState, timelineId?: string): SubtitleClip[] {
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
return timeline?.subtitles || EMPTY_SUBTITLES
}
export function selectSelectedClipIds(state: EditorState): Set<string> {
return state.session.selection.clipIds
}
export function selectSelectedSubtitleId(state: EditorState): string | null {
return state.session.selection.subtitleId
}
export function selectEditingSubtitleId(state: EditorState): string | null {
return state.session.selection.editingSubtitleId
}
export function selectSelectedGap(state: EditorState): TimelineGapSelection | null {
return state.session.selection.gap
}
export function selectSelectedClips(state: EditorState): TimelineClip[] {
const selectedIds = selectSelectedClipIds(state)
return selectClips(state).filter(clip => selectedIds.has(clip.id))
}
export function selectSingleSelectedClip(state: EditorState): TimelineClip | null {
const selectedIds = [...selectSelectedClipIds(state)]
if (selectedIds.length !== 1) return null
return selectClips(state).find(clip => clip.id === selectedIds[0]) ?? null
}
export function selectSelectedLinkedGroup(state: EditorState, clipId: string): TimelineClip[] {
const clips = selectClips(state)
const first = clips.find(clip => clip.id === clipId)
if (!first) return []
const linkedGroup = new Set([first.id])
const queue = [first.id]
while (queue.length > 0) {
const id = queue.pop()!
const clip = clips.find(candidate => candidate.id === id)
if (!clip?.linkedClipIds) continue
for (const linkedId of clip.linkedClipIds) {
if (!linkedGroup.has(linkedId) && clips.some(candidate => candidate.id === linkedId)) {
linkedGroup.add(linkedId)
queue.push(linkedId)
}
}
}
return clips.filter(clip => linkedGroup.has(clip.id))
}
export function selectSelectedClipForProperties(state: EditorState): TimelineClip | null {
const clips = selectClips(state)
const selectedClipIds = selectSelectedClipIds(state)
if (selectedClipIds.size === 0) return null
if (selectedClipIds.size === 1) return clips.find(clip => clip.id === [...selectedClipIds][0]) ?? null
const selectedIds = [...selectedClipIds]
const first = clips.find(clip => clip.id === selectedIds[0])
if (!first) return null
const linkedGroup = selectSelectedLinkedGroup(state, first.id)
const linkedIdSet = new Set(linkedGroup.map(clip => clip.id))
const allInGroup = selectedIds.every(id => linkedIdSet.has(id)) && linkedIdSet.size === selectedClipIds.size
if (!allInGroup) return null
return linkedGroup.find(clip => clip.type === 'video' || clip.type === 'image') ?? first
}
export function selectClipAudioControls(
state: EditorState,
clipId: string | null | undefined,
): ClipAudioControlsModel | null {
const clip = selectClipById(state, clipId)
if (!clip) return null
if (clip.type === 'audio') {
return {
targetClipId: clip.id,
muted: clip.muted || false,
volume: clip.volume ?? 1,
}
}
const linkedAudioClip = (clip.linkedClipIds || [])
.map(linkedId => selectClipById(state, linkedId))
.find(candidate => candidate?.type === 'audio')
const targetClip = linkedAudioClip ?? clip
return {
targetClipId: targetClip.id,
muted: targetClip.muted || false,
volume: targetClip.volume ?? 1,
}
}
export function selectSelectedClipAudioControls(state: EditorState): ClipAudioControlsModel | null {
const clip = selectSelectedClipForProperties(state)
if (!clip) return null
return selectClipAudioControls(state, clip.id)
}
export function selectSelectedSubtitle(state: EditorState): SubtitleClip | null {
const subtitleId = selectSelectedSubtitleId(state)
if (!subtitleId) return null
return selectSubtitles(state).find(subtitle => subtitle.id === subtitleId) ?? null
}
export function selectOpenTimelines(state: EditorState): Timeline[] {
const openIds = state.session.ui.openTimelineIds
return state.editorModel.timelines.filter(timeline => openIds.has(timeline.id))
}
export function selectOpenTimelineIds(state: EditorState): Set<string> {
return state.session.ui.openTimelineIds
}
export function selectTimelineListItems(state: EditorState): TimelineListItem[] {
const activeTimelineId = selectActiveTimelineId(state)
const openTimelineIds = state.session.ui.openTimelineIds
const renamingTimelineId = state.session.ui.renamingTimelineId
return state.editorModel.timelines.map(timeline => ({
timeline,
isActive: timeline.id === activeTimelineId,
isOpen: openTimelineIds.has(timeline.id),
isRenaming: renamingTimelineId === timeline.id,
clipCount: timeline.clips.length,
duration: timeline.clips.reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0),
}))
}
export function selectCurrentTime(state: EditorState): number {
return state.session.transport.currentTime
}
export function selectIsPlaying(state: EditorState): boolean {
return state.session.transport.isPlaying
}
export function selectShuttleSpeed(state: EditorState): number {
return state.session.transport.shuttleSpeed
}
export function selectPlayingInOut(state: EditorState): boolean {
return state.session.transport.playingInOut
}
export function selectTimelineInOutMap(state: EditorState): Record<string, TimelineInOutRange> {
return state.session.transport.timelineInOutMap
}
export function selectActiveTimelineInOutRange(state: EditorState): TimelineInOutRange {
const activeTimelineId = selectActiveTimelineId(state) || ''
return state.session.transport.timelineInOutMap[activeTimelineId] || EMPTY_TIMELINE_IN_OUT_RANGE
}
export function selectActiveTimelineInPoint(state: EditorState): number | null {
return selectActiveTimelineInOutRange(state).inPoint
}
export function selectActiveTimelineOutPoint(state: EditorState): number | null {
return selectActiveTimelineInOutRange(state).outPoint
}
export function selectTotalDuration(state: EditorState): number {
return Math.max(
selectClips(state).reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0),
30,
)
}
export function selectZoom(state: EditorState): number {
return state.session.tools.zoom
}
export function selectPixelsPerSecond(state: EditorState): number {
return 100 * selectZoom(state)
}
export function selectSnapEnabled(state: EditorState): boolean {
return state.session.tools.snapEnabled
}
export function selectActiveTool(state: EditorState): ToolType {
return state.session.tools.activeTool
}
export function selectLastTrimTool(state: EditorState): ToolType {
return state.session.tools.lastTrimTool
}
export function selectShowSourceMonitor(state: EditorState): boolean {
return state.session.ui.showSourceMonitor
}
export function selectShowImportTimelineModal(state: EditorState): boolean {
return state.session.ui.showImportTimelineModal
}
export function selectShowExportModal(state: EditorState): boolean {
return state.session.ui.showExportModal
}
export function selectShowPropertiesPanel(state: EditorState): boolean {
return state.session.ui.showPropertiesPanel
}
export function selectShowEffectsBrowser(state: EditorState): boolean {
return state.session.ui.showEffectsBrowser
}
export function selectActiveFocusArea(state: EditorState): 'source' | 'timeline' {
return state.session.ui.activeFocusArea
}
export function selectSourceSplitPercent(state: EditorState): number {
return state.session.ui.sourceSplitPercent
}
export function selectHasSourceAsset(state: EditorState): boolean {
return state.session.ui.hasSourceAsset
}
export function selectTimelineRenameState(state: EditorState): Pick<EditorState['session']['ui'], 'renamingTimelineId' | 'renameValue' | 'renameSource'> {
return {
renamingTimelineId: state.session.ui.renamingTimelineId,
renameValue: state.session.ui.renameValue,
renameSource: state.session.ui.renameSource,
}
}
export function selectLayout(state: EditorState) {
return state.session.ui.layout
}
export function selectSubtitleTrackStyleIdx(state: EditorState): number | null {
return state.session.ui.subtitleTrackStyleIdx
}
export function selectSubtitleTrackStyleEditorModel(state: EditorState): SubtitleTrackStyleEditorModel | null {
const trackIndex = selectSubtitleTrackStyleIdx(state)
if (trackIndex === null) return null
const track = selectTracks(state)[trackIndex]
if (!track || track.type !== 'subtitle') return null
return {
trackIndex,
track,
style: {
...DEFAULT_SUBTITLE_STYLE,
...(track.subtitleStyle || {}),
},
}
}
export function selectGapGenerateMode(state: EditorState): EditorState['session']['ui']['gapGenerateMode'] {
return state.session.ui.gapGenerateMode
}
export function selectAssetById(state: EditorState, assetId: string | null | undefined): Asset | undefined {
if (!assetId) return undefined
return state.editorModel.assets.find(asset => asset.id === assetId)
}
function findAssetById(assets: Asset[], assetId: string | null | undefined): Asset | undefined {
if (!assetId) return undefined
return assets.find(asset => asset.id === assetId)
}
export function selectLiveAssetForClipFromAssets(
assets: Asset[],
clip: TimelineClip | null | undefined,
): Asset | null | undefined {
if (!clip) return null
if (!clip.assetId) return clip.asset
return findAssetById(assets, clip.assetId) || clip.asset
}
export function selectLiveAssetForClip(state: EditorState, clip: TimelineClip | null | undefined): Asset | null | undefined {
return selectLiveAssetForClipFromAssets(selectAssets(state), clip)
}
export function selectClipPathFromAssets(assets: Asset[], clip: TimelineClip | null | undefined): string {
if (!clip) return ''
const liveAsset = selectLiveAssetForClipFromAssets(assets, clip)
let src = clip.asset?.path || ''
if (liveAsset) {
if (liveAsset.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
src = liveAsset.takes[idx].path
} else {
src = liveAsset.path
}
}
return src || ''
}
export function selectClipPath(state: EditorState, clip: TimelineClip | null | undefined): string {
return selectClipPathFromAssets(selectAssets(state), clip)
}
export function selectClipDimensionsFromAssets(assets: Asset[], clip: TimelineClip): ClipDimensions | null {
if (clip.type === 'audio') return null
const liveAsset = selectLiveAssetForClipFromAssets(assets, 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
}
export function selectClipDimensions(state: EditorState, clip: TimelineClip): ClipDimensions | null {
return selectClipDimensionsFromAssets(selectAssets(state), clip)
}
export function selectClipResolutionFromAssets(assets: Asset[], clip: TimelineClip): ClipResolutionInfo | null {
const dims = selectClipDimensionsFromAssets(assets, clip)
if (!dims) return null
const tier = namedResolutionTier(Math.min(dims.width, dims.height))
const displayName = namedResolutionDisplayName(tier)
const color = tier >= 2160 ? '#22c55e' : tier >= 1080 ? '#3b82f6' : tier >= 720 ? '#f59e0b' : '#ef4444'
return {
label: `${displayName} (${dims.width}x${dims.height})`,
color,
height: dims.height,
displayName,
}
}
export function selectClipResolution(state: EditorState, clip: TimelineClip): ClipResolutionInfo | null {
return selectClipResolutionFromAssets(selectAssets(state), clip)
}
export function selectClipColorLabel(state: EditorState, clip: TimelineClip): string | undefined {
const liveAsset = selectLiveAssetForClip(state, clip)
const label = clip.colorLabel || liveAsset?.colorLabel || clip.asset?.colorLabel
return COLOR_LABELS.find((color) => color.id === label)?.id
}
export function selectClipMaxDurationFromAssets(assets: Asset[], clip: TimelineClip): number {
const liveAsset = selectLiveAssetForClipFromAssets(assets, 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)
}
export function selectClipMaxDuration(state: EditorState, clip: TimelineClip): number {
return selectClipMaxDurationFromAssets(selectAssets(state), clip)
}
export function selectOrderedTracks(state: EditorState): OrderedTrackEntry[] {
const tracks = selectTracks(state)
const videoTracks: { track: Track; realIndex: number }[] = []
const audioTracks: { track: Track; realIndex: number }[] = []
const subtitleTracks: { track: Track; realIndex: number }[] = []
tracks.forEach((track, realIndex) => {
if (track.type === 'subtitle') subtitleTracks.push({ track, realIndex })
else if (track.kind === 'audio') audioTracks.push({ track, realIndex })
else videoTracks.push({ track, realIndex })
})
videoTracks.reverse()
return [...subtitleTracks, ...videoTracks, ...audioTracks].map((entry, displayRow) => ({
...entry,
displayRow,
}))
}
export function selectTrackDisplayRows(state: EditorState): Map<number, number> {
const map = new Map<number, number>()
selectOrderedTracks(state).forEach(entry => map.set(entry.realIndex, entry.displayRow))
return map
}
export function selectAudioDividerDisplayRow(state: EditorState): number {
const firstAudio = selectOrderedTracks(state).find(entry => entry.track.kind === 'audio')
return firstAudio?.displayRow ?? -1
}
export function selectTimelineGaps(state: EditorState): TimelineGapSelection[] {
const clips = selectClips(state)
const tracks = selectTracks(state)
const gaps: TimelineGapSelection[] = []
tracks.forEach((track, trackIndex) => {
if (track.type === 'subtitle') return
const trackClips = clips
.filter(clip => clip.trackIndex === trackIndex)
.sort((a, b) => a.startTime - b.startTime)
if (trackClips.length === 0) return
if (trackClips[0].startTime > 0.05) {
gaps.push({ trackIndex, startTime: 0, endTime: trackClips[0].startTime })
}
for (let i = 0; i < trackClips.length - 1; i++) {
const currentEnd = trackClips[i].startTime + trackClips[i].duration
const nextStart = trackClips[i + 1].startTime
if (nextStart - currentEnd > 0.05) {
gaps.push({ trackIndex, startTime: currentEnd, endTime: nextStart })
}
}
})
return gaps
}
export function selectCutPoints(state: EditorState): TimelineCutPoint[] {
const clips = selectClips(state)
const points: TimelineCutPoint[] = []
const byTrack = new Map<number, TimelineClip[]>()
for (const clip of clips) {
if (!byTrack.has(clip.trackIndex)) byTrack.set(clip.trackIndex, [])
byTrack.get(clip.trackIndex)!.push(clip)
}
for (const [trackIndex, trackClips] of byTrack) {
const sorted = [...trackClips].sort((a, b) => a.startTime - b.startTime)
for (let i = 0; i < sorted.length - 1; i++) {
const leftClip = sorted[i]
const rightClip = sorted[i + 1]
const leftEnd = leftClip.startTime + leftClip.duration
if (Math.abs(leftEnd - rightClip.startTime) < CUT_POINT_TOLERANCE) {
points.push({
leftClip,
rightClip,
time: leftEnd,
trackIndex,
hasDissolve: (leftClip.transitionOut?.type === 'dissolve') || (rightClip.transitionIn?.type === 'dissolve'),
})
}
}
}
return points
}
export function selectKeyboardCommandContext(state: EditorState): KeyboardCommandContext {
return {
clips: selectClips(state),
selectedClipIds: selectSelectedClipIds(state),
totalDuration: selectTotalDuration(state),
currentTime: selectCurrentTime(state),
inPoint: selectActiveTimelineInPoint(state),
outPoint: selectActiveTimelineOutPoint(state),
}
}
export function selectMenuState(state: EditorState): MenuState {
return {
selectedClip: selectSelectedClipForProperties(state),
selectedClipIds: selectSelectedClipIds(state),
clips: selectClips(state),
tracks: selectTracks(state),
subtitles: selectSubtitles(state),
snapEnabled: selectSnapEnabled(state),
showEffectsBrowser: selectShowEffectsBrowser(state),
showSourceMonitor: selectShowSourceMonitor(state),
showPropertiesPanel: selectShowPropertiesPanel(state),
hasSourceAsset: selectHasSourceAsset(state),
activeTool: selectActiveTool(state),
activeTimeline: selectActiveTimeline(state),
timelines: selectTimelines(state),
}
}
export function selectCanUseClipboard(state: EditorState): boolean {
return state.session.clipboard.kind === 'clips' && state.session.clipboard.clips.length > 0
}
export function selectCanInsertEdit(state: EditorState): boolean {
return selectHasSourceAsset(state)
}
export function selectCanOverwriteEdit(state: EditorState): boolean {
return selectHasSourceAsset(state)
}
export function selectClipMetadata(state: EditorState, clip: TimelineClip): ClipMetadata {
const liveAsset = selectLiveAssetForClip(state, clip)
const resolution = selectClipResolution(state, clip)
const dimensions = selectClipDimensions(state, clip)
const generationParams = liveAsset?.generationParams
const totalTakes = liveAsset?.takes?.length || 1
const currentTakeIdx = clip.takeIndex ?? (liveAsset?.activeTakeIndex ?? (totalTakes - 1))
const displayTakeNum = Math.min(currentTakeIdx, totalTakes - 1) + 1
let filePath = liveAsset?.path || ''
if (liveAsset?.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
filePath = liveAsset.takes[idx].path
}
const originalRes = liveAsset?.generationParams?.resolution
const isUpscaled = dimensions && originalRes
? namedResolutionTier(Math.min(dimensions.width, dimensions.height)) > parseInt(originalRes, 10)
: false
return {
liveAsset,
dimensions,
resolution,
generationParams,
totalTakes,
currentTakeIdx,
displayTakeNum,
filePath,
isUpscaled,
}
}
export function selectClipCapabilities(state: EditorState, clip: TimelineClip): ClipCapabilities {
const liveAsset = selectLiveAssetForClip(state, clip)
return {
isVideo: clip.type === 'video',
isImage: clip.type === 'image',
isAudio: clip.type === 'audio',
isAdjustment: clip.type === 'adjustment',
isText: clip.type === 'text',
canCreateVideoFromImage: clip.type === 'image',
canCreateVideoFromAudio: clip.type === 'audio' && !clip.linkedClipIds?.length,
canRegenerate: Boolean(liveAsset?.generationParams),
canRetake: clip.type === 'video',
canUseIcLora: clip.type === 'video',
}
}
export function selectSelectedClipProperties(state: EditorState): SelectedClipPropertiesModel | null {
const clip = selectSelectedClipForProperties(state)
if (!clip) return null
return {
clip,
metadata: selectClipMetadata(state, clip),
capabilities: selectClipCapabilities(state, clip),
}
}
export function selectSelectedSubtitleEditorModel(state: EditorState): SelectedSubtitleEditorModel | null {
const subtitle = selectSelectedSubtitle(state)
if (!subtitle) return null
const track = selectTracks(state)[subtitle.trackIndex]
const effectiveStyle: Partial<SubtitleStyle> = {
...(track?.subtitleStyle || {}),
...(subtitle.style || {}),
}
return { subtitle, track, effectiveStyle }
}
export function selectExportLetterbox(state: EditorState): ExportLetterbox | null {
const clips = selectClips(state)
const tracks = selectTracks(state)
const adjustmentClips = clips.filter(
clip =>
clip.type === 'adjustment'
&& clip.letterbox?.enabled
&& tracks[clip.trackIndex]?.enabled !== false,
)
if (adjustmentClips.length === 0) return null
const best = adjustmentClips.reduce((currentBest, candidate) => (
candidate.duration > currentBest.duration ? candidate : currentBest
))
const letterbox = best.letterbox!
return {
ratio: letterbox.aspectRatio === 'custom'
? (letterbox.customRatio || 2.35)
: (LETTERBOX_RATIO_MAP[letterbox.aspectRatio] || 2.35),
color: letterbox.color || '#000000',
opacity: (letterbox.opacity ?? 100) / 100,
}
}
export function selectExportClipData(state: EditorState): ExportClipData[] {
const tracks = selectTracks(state)
return selectClips(state)
.filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio')
.filter(clip => tracks[clip.trackIndex]?.enabled !== false)
.map(clip => ({
path: selectClipPath(state, clip),
type: clip.type,
startTime: clip.startTime,
duration: clip.duration,
trimStart: clip.trimStart,
speed: clip.speed || 1,
reversed: clip.reversed || false,
flipH: clip.flipH || false,
flipV: clip.flipV || false,
opacity: clip.opacity ?? 100,
trackIndex: clip.trackIndex,
muted: clip.muted || false,
volume: clip.volume ?? 1,
}))
}
export function selectExportSubtitleData(state: EditorState): ExportSubtitleData[] {
const subtitles = selectSubtitles(state)
const tracks = selectTracks(state)
return subtitles.map(subtitle => {
const track = tracks[subtitle.trackIndex]
return {
text: subtitle.text,
startTime: subtitle.startTime,
endTime: subtitle.endTime,
style: {
...DEFAULT_SUBTITLE_STYLE,
...(track?.subtitleStyle || {}),
...(subtitle.style || {}),
},
}
})
}
export function selectExportModalModel(state: EditorState): ExportModalModel {
return {
timeline: selectActiveTimeline(state),
clips: selectClips(state),
tracks: selectTracks(state),
exportClips: selectExportClipData(state),
subtitleData: selectExportSubtitleData(state),
letterbox: selectExportLetterbox(state),
}
}
export function selectRegenerationState(state: EditorState): EditorState['session']['regeneration'] {
return state.session.regeneration
}
export function selectRegeneratingAssetId(state: EditorState): string | null {
return state.session.regeneration.regeneratingAssetId
}
export function selectRegeneratingClipId(state: EditorState): string | null {
return state.session.regeneration.regeneratingClipId
}
export function selectRegenerationPreError(state: EditorState): string | null {
return state.session.regeneration.preError
}
export function selectRegenerationTargetClip(state: EditorState): TimelineClip | null {
const clipId = state.session.regeneration.regeneratingClipId
if (!clipId) return null
return selectClips(state).find(clip => clip.id === clipId) ?? null
}
export function selectRegenerationTargetAsset(state: EditorState): Asset | undefined {
const clip = selectRegenerationTargetClip(state)
const assetId = state.session.regeneration.regeneratingAssetId || clip?.assetId
return selectAssetById(state, assetId)
}
export function selectCanRegenerateClip(state: EditorState, clipId: string): boolean {
const clip = selectClips(state).find(candidate => candidate.id === clipId)
if (!clip) return false
return Boolean(selectLiveAssetForClip(state, clip)?.generationParams)
}
export interface AssetBinListItem {
id: string
name: string
count: number
}
export function selectAssetBins(state: EditorState): AssetBinListItem[] {
const assetCounts = state.editorModel.assets.reduce((counts, asset) => {
if (!asset.binId) return counts
counts.set(asset.binId, (counts.get(asset.binId) ?? 0) + 1)
return counts
}, new Map<string, number>())
return Object.entries(state.editorModel.bins)
.map(([id, name]) => ({
id,
name,
count: assetCounts.get(id) ?? 0,
}))
.sort((left, right) => left.name.localeCompare(right.name))
}
export function equalAssetBins(left: AssetBinListItem[], right: AssetBinListItem[]): boolean {
if (left === right) return true
if (left.length !== right.length) return false
return left.every((bin, index) => {
const other = right[index]
return (
bin.id === other.id
&& bin.name === other.name
&& bin.count === other.count
)
})
}
export function selectAssetTakesView(state: EditorState, assetId: string): AssetTakeView {
const asset = selectAssetById(state, assetId)
return {
asset,
takes: asset?.takes || [],
activeTakeIndex: asset?.activeTakeIndex ?? 0,
}
}
export function selectFilteredAssets(state: EditorState, filters: AssetListFilters): Asset[] {
let result = selectAssets(state)
if (filters.assetFilter && filters.assetFilter !== 'all') {
result = result.filter(asset => asset.type === filters.assetFilter)
}
if (filters.selectedBinId !== undefined && filters.selectedBinId !== null) {
result = result.filter(asset => asset.binId === filters.selectedBinId)
}
return result
}
export function selectSortedAssets(state: EditorState, filters: AssetListFilters): Asset[] {
const filteredAssets = selectFilteredAssets(state, filters)
if (filters.assetViewMode !== 'list') return filteredAssets
const sorted = [...filteredAssets]
const dir = filters.listSortDir === 'desc' ? -1 : 1
sorted.sort((a, b) => {
switch (filters.listSortCol) {
case 'type':
return dir * a.type.localeCompare(b.type)
case 'duration':
return dir * ((a.duration ?? 0) - (b.duration ?? 0))
case 'resolution':
return dir * (parseResolutionHeight(a.resolution) - parseResolutionHeight(b.resolution))
case 'date':
return dir * (a.createdAt - b.createdAt)
case 'color': {
const order = COLOR_LABELS.map((color) => color.id)
const idxA = a.colorLabel ? order.indexOf(a.colorLabel) : order.length
const idxB = b.colorLabel ? order.indexOf(b.colorLabel) : order.length
return dir * (idxA - idxB)
}
case 'name':
default: {
const nameA = (a.path?.split(/[/\\]/).pop() || a.type || '').toLowerCase()
const nameB = (b.path?.split(/[/\\]/).pop() || b.type || '').toLowerCase()
return dir * nameA.localeCompare(nameB)
}
}
})
return sorted
}
export function selectVisibleAssets(state: EditorState, filters: AssetListFilters): Asset[] {
return selectSortedAssets(state, filters)
}
+333
View File
@@ -0,0 +1,333 @@
import type {
Asset,
AssetTake,
AssetBins,
GenerationParams,
SubtitleClip,
SubtitleStyle,
Timeline,
TimelineClip,
Track,
} from '../../types/project-model'
import {
DEFAULT_LAYOUT,
type EditorLayout,
type ToolType,
} from './video-editor-utils'
export interface EditorModel {
assets: Asset[]
bins: AssetBins
timelines: Timeline[]
activeTimelineId: string | null
}
export interface TimelineGapSelection {
trackIndex: number
startTime: number
endTime: number
}
export interface TimelineInOutRange {
inPoint: number | null
outPoint: number | null
}
export interface EditorUndoSnapshot {
assets: Asset[]
bins: AssetBins
timelines: Timeline[]
}
export interface EditorClipboardState {
kind: 'clips' | null
clips: TimelineClip[]
copiedFromTimelineId: string | null
}
export interface EditorHistoryState {
undoStack: EditorUndoSnapshot[]
redoStack: EditorUndoSnapshot[]
}
export interface EditorProjectSyncState {
dirty: boolean
}
export interface EditorSelectionState {
clipIds: Set<string>
subtitleId: string | null
editingSubtitleId: string | null
gap: TimelineGapSelection | null
}
export interface EditorTransportState {
currentTime: number
isPlaying: boolean
shuttleSpeed: number
playingInOut: boolean
timelineInOutMap: Record<string, TimelineInOutRange>
}
export interface EditorToolsState {
zoom: number
snapEnabled: boolean
activeTool: ToolType
lastTrimTool: ToolType
}
export interface EditorUiState {
showImportTimelineModal: boolean
showExportModal: boolean
showSourceMonitor: boolean
showPropertiesPanel: boolean
showEffectsBrowser: boolean
activeFocusArea: 'source' | 'timeline'
sourceSplitPercent: number
hasSourceAsset: boolean
openTimelineIds: Set<string>
renamingTimelineId: string | null
renameValue: string
renameSource: 'tab' | 'panel'
layout: EditorLayout
subtitleTrackStyleIdx: number | null
gapGenerateMode: 'text-to-video' | 'image-to-video' | 'text-to-image' | null
}
export interface EditorRegenerationState {
regeneratingAssetId: string | null
regeneratingClipId: string | null
preError: string | null
}
export interface EditorSessionState {
selection: EditorSelectionState
transport: EditorTransportState
tools: EditorToolsState
ui: EditorUiState
regeneration: EditorRegenerationState
clipboard: EditorClipboardState
}
export interface EditorState {
editorModel: EditorModel
session: EditorSessionState
history: EditorHistoryState
projectSync: EditorProjectSyncState
}
export interface TimelineListItem {
timeline: Timeline
isActive: boolean
isOpen: boolean
isRenaming: boolean
clipCount: number
duration: number
}
export interface ClipDimensions {
width: number
height: number
}
export interface ClipResolutionInfo {
label: string
color: string
height: number
displayName: string
}
export interface OrderedTrackEntry {
track: Track
realIndex: number
displayRow: number
}
export interface TimelineCutPoint {
leftClip: TimelineClip
rightClip: TimelineClip
time: number
trackIndex: number
hasDissolve: boolean
}
export interface ClipMetadata {
liveAsset: Asset | null | undefined
dimensions: ClipDimensions | null
resolution: ClipResolutionInfo | null
generationParams: GenerationParams | undefined
totalTakes: number
currentTakeIdx: number
displayTakeNum: number
filePath: string
isUpscaled: boolean
}
export interface ClipCapabilities {
isVideo: boolean
isImage: boolean
isAudio: boolean
isAdjustment: boolean
isText: boolean
canCreateVideoFromImage: boolean
canCreateVideoFromAudio: boolean
canRegenerate: boolean
canRetake: boolean
canUseIcLora: boolean
}
export interface SelectedClipPropertiesModel {
clip: TimelineClip
metadata: ClipMetadata
capabilities: ClipCapabilities
}
export interface SelectedSubtitleEditorModel {
subtitle: SubtitleClip
track: Track | undefined
effectiveStyle: Partial<SubtitleStyle>
}
export interface SubtitleTrackStyleEditorModel {
trackIndex: number
track: Track
style: SubtitleStyle
}
export interface KeyboardCommandContext {
clips: TimelineClip[]
selectedClipIds: Set<string>
totalDuration: number
currentTime: number
inPoint: number | null
outPoint: number | null
}
export interface MenuState {
selectedClip: TimelineClip | null
selectedClipIds: Set<string>
clips: TimelineClip[]
tracks: Track[]
subtitles: SubtitleClip[]
snapEnabled: boolean
showEffectsBrowser: boolean
showSourceMonitor: boolean
showPropertiesPanel: boolean
hasSourceAsset: boolean
activeTool: ToolType
activeTimeline: Timeline | null
timelines: Timeline[]
}
export interface AssetListFilters {
assetFilter?: Asset['type'] | 'all'
selectedBinId?: string | null
assetViewMode?: 'grid' | 'list'
listSortCol?: 'name' | 'type' | 'duration' | 'resolution' | 'date' | 'color'
listSortDir?: 'asc' | 'desc'
}
export interface AssetTakeView {
asset: Asset | undefined
takes: AssetTake[]
activeTakeIndex: number
}
export const EMPTY_IN_OUT_RANGE: TimelineInOutRange = {
inPoint: null,
outPoint: null,
}
export function createInitialEditorState(
editorModel: EditorModel,
layout: EditorLayout = DEFAULT_LAYOUT,
): EditorState {
return {
editorModel,
session: {
selection: {
clipIds: new Set(),
subtitleId: null,
editingSubtitleId: null,
gap: null,
},
transport: {
currentTime: 0,
isPlaying: false,
shuttleSpeed: 0,
playingInOut: false,
timelineInOutMap: {},
},
tools: {
zoom: 1,
snapEnabled: true,
activeTool: 'select',
lastTrimTool: 'ripple',
},
ui: {
showImportTimelineModal: false,
showExportModal: false,
showSourceMonitor: false,
showPropertiesPanel: false,
showEffectsBrowser: false,
activeFocusArea: 'timeline',
sourceSplitPercent: 50,
hasSourceAsset: false,
openTimelineIds: editorModel.activeTimelineId ? new Set([editorModel.activeTimelineId]) : new Set(),
renamingTimelineId: null,
renameValue: '',
renameSource: 'tab',
layout,
subtitleTrackStyleIdx: null,
gapGenerateMode: null,
},
regeneration: {
regeneratingAssetId: null,
regeneratingClipId: null,
preError: null,
},
clipboard: {
kind: null,
clips: [],
copiedFromTimelineId: null,
},
},
history: {
undoStack: [],
redoStack: [],
},
projectSync: {
dirty: false,
},
}
}
export function getUndoSnapshot(state: EditorState): EditorUndoSnapshot {
return {
assets: state.editorModel.assets,
bins: state.editorModel.bins,
timelines: state.editorModel.timelines,
}
}
export function applyUndoSnapshot(
state: EditorState,
snapshot: EditorUndoSnapshot,
): EditorState {
return {
...state,
editorModel: {
...state.editorModel,
assets: snapshot.assets,
bins: snapshot.bins,
timelines: snapshot.timelines,
},
}
}
export function equalUndoSnapshot(
left: EditorUndoSnapshot,
right: EditorUndoSnapshot,
): boolean {
return left.assets === right.assets && left.bins === right.bins && left.timelines === right.timelines
}
+140
View File
@@ -0,0 +1,140 @@
import React, { createContext, useCallback, useContext, useMemo } from 'react'
import { createStore, type StoreApi } from 'zustand/vanilla'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import type { EditorState } from './editor-state'
import { equalUndoSnapshot, getUndoSnapshot } from './editor-state'
import * as editorActions from './editor-actions'
const MAX_UNDO_HISTORY = 50
type EditorSetStateAction = React.SetStateAction<EditorState>
interface EditorStore {
state: EditorState
setStateWithHistory: (value: EditorSetStateAction) => void
setStateWithoutHistory: (value: EditorSetStateAction) => void
}
export type EditorStoreApi = StoreApi<EditorStore>
function resolveStateAction(value: EditorSetStateAction, prev: EditorState): EditorState {
return typeof value === 'function'
? (value as (prevState: EditorState) => EditorState)(prev)
: value
}
export function createEditorStore(initialState: EditorState): EditorStoreApi {
return createStore<EditorStore>()((set) => ({
state: initialState,
setStateWithHistory: (value) => {
set((prev) => ({
state: recordHistoryStep(prev.state, resolveStateAction(value, prev.state)),
}))
},
setStateWithoutHistory: (value) => {
set((prev) => ({
state: resolveStateAction(value, prev.state),
}))
},
}))
}
function recordHistoryStep(prev: EditorState, next: EditorState): EditorState {
if (next === prev) return prev
const beforeSnapshot = getUndoSnapshot(prev)
const afterSnapshot = getUndoSnapshot(next)
if (equalUndoSnapshot(beforeSnapshot, afterSnapshot)) return next
return {
...next,
history: {
undoStack: [
...prev.history.undoStack.slice(-(MAX_UNDO_HISTORY - 1)),
beforeSnapshot,
],
redoStack: [],
},
}
}
const EditorStoreContext = createContext<EditorStoreApi | null>(null)
export interface EditorStoreProviderProps {
store: EditorStoreApi
children: React.ReactNode
}
export function EditorStoreProvider({ store, children }: EditorStoreProviderProps) {
return (
<EditorStoreContext.Provider value={store}>
{children}
</EditorStoreContext.Provider>
)
}
function useEditorStoreContext(): EditorStoreApi {
const store = useContext(EditorStoreContext)
if (!store) throw new Error('EditorStoreProvider is missing')
return store
}
export function useEditorStore<T>(
selector: (state: EditorState) => T,
equalityFn?: (left: T, right: T) => boolean,
): T {
const store = useEditorStoreContext()
return useStoreWithEqualityFn(store, state => selector(state.state), equalityFn)
}
export function useEditorGetState(): () => EditorState {
const store = useEditorStoreContext()
return useCallback(() => store.getState().state, [store])
}
function useEditorStoreSetStateWithHistory(): (value: EditorSetStateAction) => void {
const store = useEditorStoreContext()
return useCallback((value: EditorSetStateAction) => {
store.getState().setStateWithHistory(value)
}, [store])
}
function useEditorStoreSetStateWithoutHistory(): (value: EditorSetStateAction) => void {
const store = useEditorStoreContext()
return useCallback((value: EditorSetStateAction) => {
store.getState().setStateWithoutHistory(value)
}, [store])
}
type EditorActionModule = typeof editorActions
type ReducerActionKeys = {
[K in keyof EditorActionModule]:
EditorActionModule[K] extends (state: EditorState, ...args: any[]) => EditorState ? K : never
}[keyof EditorActionModule]
export type EditorActions = {
[K in ReducerActionKeys]:
EditorActionModule[K] extends (state: EditorState, ...args: infer A) => EditorState
? (...args: A) => void
: never
}
export function useEditorActions(): EditorActions {
const setStateWithHistory = useEditorStoreSetStateWithHistory()
const setStateWithoutHistory = useEditorStoreSetStateWithoutHistory()
return useMemo(() => {
const actions = {} as EditorActions
const source = editorActions as Record<string, (...args: any[]) => any>
for (const [key, action] of Object.entries(source)) {
;(actions as Record<string, (...args: any[]) => void>)[key] = (...args: any[]) => {
const apply = key === 'undo' || key === 'redo'
? setStateWithoutHistory
: setStateWithHistory
apply(prev => action(prev, ...args))
}
}
return actions
}, [setStateWithHistory, setStateWithoutHistory])
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useRef } from 'react'
import { resolveAction, type ActionId } from '../../lib/keyboard-shortcuts'
import type { EditorState } from './editor-state'
import type { SourceKeyboardAction } from './VideoEditorSourceMonitor'
import {
selectActiveFocusArea,
selectActiveTimelineInPoint,
selectActiveTimelineOutPoint,
selectClips,
selectKeyboardCommandContext,
selectSelectedGap,
selectSelectedSubtitleId,
selectShuttleSpeed,
selectTracks,
} from './editor-selectors'
import { useEditorActions } from './editor-store'
// Frame duration at 24fps
const FRAME_DURATION = 1 / 24
const FORWARD_SPEEDS = [1, 2, 4, 8]
const REVERSE_SPEEDS = [-1, -2, -4, -8]
interface KeyboardRefs {
kbLayoutRef: React.MutableRefObject<any>
isKbEditorOpenRef: React.MutableRefObject<boolean>
getState: () => EditorState
playbackTimeRef: React.MutableRefObject<number>
sourceDispatchRef: React.MutableRefObject<(action: SourceKeyboardAction) => void>
sourcePauseRef: React.MutableRefObject<() => void>
centerOnPlayheadRef: React.MutableRefObject<boolean>
getMinZoomRef: React.MutableRefObject<() => number>
gapGenerateModeRef: React.MutableRefObject<'text-to-video' | 'image-to-video' | 'text-to-image' | null>
clearSelectedGapRef: React.MutableRefObject<() => void>
closeSelectedGapRef: React.MutableRefObject<() => void>
fitToViewRef: React.MutableRefObject<() => void>
toggleFullscreenRef: React.MutableRefObject<() => void>
insertEditRef: React.MutableRefObject<() => void>
overwriteEditRef: React.MutableRefObject<() => void>
matchFrameRef: React.MutableRefObject<() => void>
}
interface KeyboardContext {
deleteAssetActionRef: React.MutableRefObject<() => void>
}
export interface UseEditorKeyboardParams {
refs: KeyboardRefs
context: KeyboardContext
}
export function useEditorKeyboard(params: UseEditorKeyboardParams) {
const { refs, context } = params
const actions = useEditorActions()
const kHeldRef = useRef(false)
const contextRef = useRef(context)
const actionsRef = useRef(actions)
contextRef.current = context
actionsRef.current = actions
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if (refs.isKbEditorOpenRef.current) return
const context = contextRef.current
const state = refs.getState()
const commandContext = selectKeyboardCommandContext(state)
const sel = commandContext.selectedClipIds
const td = commandContext.totalDuration
const focusArea = selectActiveFocusArea(state)
const action: ActionId | null = resolveAction(refs.kbLayoutRef.current, e)
if (!action) return
e.preventDefault()
const editorActions = actionsRef.current
switch (action) {
// Tools
case 'tool.select': editorActions.setActiveTool('select'); break
case 'tool.blade': editorActions.setActiveTool('blade'); break
case 'tool.ripple': editorActions.setActiveTool('ripple'); editorActions.setLastTrimTool('ripple'); break
case 'tool.roll': editorActions.setActiveTool('roll'); editorActions.setLastTrimTool('roll'); break
case 'tool.slide': editorActions.setActiveTool('slide'); editorActions.setLastTrimTool('slide'); break
case 'tool.slip': editorActions.setActiveTool('slip'); editorActions.setLastTrimTool('slip'); break
case 'tool.trackForward': editorActions.setActiveTool('trackForward'); break
// Transport — panel-aware
case 'transport.playPause':
if (focusArea === 'source') {
editorActions.pause()
editorActions.stopShuttle()
refs.sourceDispatchRef.current('transport.playPause')
} else {
refs.sourcePauseRef.current()
const wasPlaying = state.session.transport.isPlaying
const hadShuttle = state.session.transport.shuttleSpeed !== 0
editorActions.stopShuttle()
if (wasPlaying || hadShuttle) editorActions.pause()
else editorActions.play()
}
break
case 'transport.shuttleReverse':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.shuttleReverse')
} else {
refs.sourcePauseRef.current()
if (kHeldRef.current) {
editorActions.pause()
editorActions.stopShuttle()
editorActions.stepCurrentTime(-FRAME_DURATION)
} else {
const currentSpeed = selectShuttleSpeed(state)
const nextSpeed = currentSpeed > 0
? -1
: REVERSE_SPEEDS[Math.max(0, Math.min(REVERSE_SPEEDS.length - 1, REVERSE_SPEEDS.indexOf(currentSpeed) + 1 || 0))]
editorActions.setShuttleSpeed(nextSpeed)
editorActions.play()
}
}
break
case 'transport.shuttleStop':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.shuttleStop')
} else {
kHeldRef.current = true
editorActions.pause()
editorActions.stopShuttle()
}
break
case 'transport.shuttleForward':
if (focusArea === 'source') {
refs.sourceDispatchRef.current(kHeldRef.current ? 'transport.stepForward' : 'transport.shuttleForward')
} else {
refs.sourcePauseRef.current()
if (kHeldRef.current) {
editorActions.pause()
editorActions.stopShuttle()
editorActions.stepCurrentTime(FRAME_DURATION)
} else {
const currentSpeed = selectShuttleSpeed(state)
const nextSpeed = currentSpeed < 0
? 1
: FORWARD_SPEEDS[Math.max(0, Math.min(FORWARD_SPEEDS.length - 1, FORWARD_SPEEDS.indexOf(currentSpeed) + 1 || 0))]
editorActions.setShuttleSpeed(nextSpeed)
editorActions.play()
}
}
break
case 'transport.stepBackward':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.stepBackward')
} else {
editorActions.stepCurrentTime(-FRAME_DURATION)
}
break
case 'transport.stepForward':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.stepForward')
} else {
const nextTime = Math.min(commandContext.totalDuration, commandContext.currentTime + FRAME_DURATION)
editorActions.setCurrentTime(nextTime)
}
break
case 'transport.jumpBackward':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.jumpBackward')
} else {
editorActions.stepCurrentTime(-1)
}
break
case 'transport.jumpForward':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('transport.jumpForward')
} else {
const nextTime = Math.min(commandContext.totalDuration, commandContext.currentTime + 1)
editorActions.setCurrentTime(nextTime)
}
break
case 'transport.goToStart':
editorActions.pause()
editorActions.stopShuttle()
editorActions.setCurrentTime(0)
break
case 'transport.goToEnd':
editorActions.pause()
editorActions.stopShuttle()
editorActions.setCurrentTime(td)
break
case 'transport.goToIn': {
const { inPoint, clips } = commandContext
const target = inPoint ?? (clips.length > 0 ? Math.min(...clips.map(c => c.startTime)) : 0)
editorActions.pause()
editorActions.stopShuttle()
editorActions.setCurrentTime(target)
break
}
case 'transport.goToOut': {
const { outPoint, clips: clipsOut, totalDuration: tdOut } = commandContext
const target = outPoint ?? (clipsOut.length > 0 ? Math.max(...clipsOut.map(c => c.startTime + c.duration)) : tdOut)
editorActions.pause()
editorActions.stopShuttle()
editorActions.setCurrentTime(target)
break
}
// Editing
case 'edit.undo': editorActions.undo(); break
case 'edit.redo': editorActions.redo(); break
case 'edit.cut': editorActions.cutSelection(); break
case 'edit.copy': editorActions.copySelection(); break
case 'edit.paste': editorActions.pasteSelection(); break
case 'edit.selectAll':
editorActions.selectAllClips()
break
case 'edit.deselect':
if (refs.gapGenerateModeRef.current) {
refs.clearSelectedGapRef.current()
} else {
editorActions.clearClipSelection()
}
break
case 'edit.delete':
if (sel.size > 0) {
const deleteIds = new Set<string>()
for (const id of sel) {
const clip = selectClips(state).find(cl => cl.id === id)
if (clip && selectTracks(state)[clip.trackIndex]?.locked) continue
deleteIds.add(id)
if (clip?.linkedClipIds) {
const allLinkedSelected = clip.linkedClipIds.every(lid => sel.has(lid))
if (allLinkedSelected) clip.linkedClipIds.forEach(lid => deleteIds.add(lid))
}
}
editorActions.deleteClips([...deleteIds])
} else if (selectSelectedGap(state)) {
refs.closeSelectedGapRef.current()
} else if (selectSelectedSubtitleId(state)) {
editorActions.deleteSubtitle(selectSelectedSubtitleId(state)!)
} else {
context.deleteAssetActionRef.current()
}
break
case 'edit.insertEdit': refs.insertEditRef.current(); break
case 'edit.overwriteEdit': refs.overwriteEditRef.current(); break
case 'edit.matchFrame': refs.matchFrameRef.current(); break
// Marking — panel-aware
case 'mark.setIn':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('mark.setIn')
} else {
const ct = commandContext.currentTime
const currentIn = selectActiveTimelineInPoint(state)
editorActions.setTimelineInPoint(currentIn !== null && Math.abs(currentIn - ct) < 0.01 ? null : ct)
}
break
case 'mark.setOut':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('mark.setOut')
} else {
const ct = commandContext.currentTime
const currentOut = selectActiveTimelineOutPoint(state)
editorActions.setTimelineOutPoint(currentOut !== null && Math.abs(currentOut - ct) < 0.01 ? null : ct)
}
break
case 'mark.clearIn':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('mark.clearIn')
} else {
editorActions.clearTimelineInPoint()
}
break
case 'mark.clearOut':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('mark.clearOut')
} else {
editorActions.clearTimelineOutPoint()
}
break
case 'mark.clearInOut':
if (focusArea === 'source') {
refs.sourceDispatchRef.current('mark.clearInOut')
} else {
editorActions.clearTimelineMarks()
}
break
// Timeline
case 'timeline.zoomIn':
refs.centerOnPlayheadRef.current = true
editorActions.setZoom(Math.min(4, +(state.session.tools.zoom + 0.25).toFixed(2)))
break
case 'timeline.zoomOut':
refs.centerOnPlayheadRef.current = true
editorActions.setZoom(Math.max(refs.getMinZoomRef.current(), +(state.session.tools.zoom - 0.25).toFixed(2)))
break
case 'timeline.fitToView':
refs.fitToViewRef.current()
break
case 'timeline.toggleSnap':
editorActions.toggleSnap()
break
case 'nav.prevEdit':
editorActions.pause()
editorActions.goToPrevEdit(state.session.transport.isPlaying ? refs.playbackTimeRef.current : state.session.transport.currentTime)
break
case 'nav.nextEdit':
editorActions.pause()
editorActions.goToNextEdit(state.session.transport.isPlaying ? refs.playbackTimeRef.current : state.session.transport.currentTime)
break
case 'view.fullscreen':
refs.toggleFullscreenRef.current()
break
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'k') {
kHeldRef.current = false
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, []) // stable - uses refs for latest state
}
@@ -0,0 +1,89 @@
import { useCallback, useRef } from 'react'
import type { Asset } from '../../types/project-model'
import { addGenericAssetToProject, addVisualAssetToProject } from '../../lib/asset-copy'
import { pathToFileUrl } from '../../lib/file-url'
import { useEditorActions } from './editor-store'
interface UseEditorMediaImportParams {
currentProjectId: string | null
}
export function useEditorMediaImport(params: UseEditorMediaImportParams) {
const { currentProjectId } = params
const { addAssetToEditor } = useEditorActions()
const fileInputRef = useRef<HTMLInputElement>(null)
const getMediaDuration = useCallback((url: string, isAudio = false): Promise<number> => {
return new Promise((resolve) => {
const media = document.createElement(isAudio ? 'audio' : 'video')
media.src = url
media.onloadedmetadata = () => resolve(media.duration)
media.onerror = () => resolve(5)
})
}, [])
const handleImportFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || !currentProjectId) return
for (const file of Array.from(files)) {
const isVideo = file.type.startsWith('video/')
const isAudio = file.type.startsWith('audio/')
const isImage = file.type.startsWith('image/')
if (!isVideo && !isAudio && !isImage) continue
const electronFilePath = window.electronAPI?.getPathForFile(file)
let persistentPath = electronFilePath || file.name
let bigThumbnailPath: string | undefined
let smallThumbnailPath: string | undefined
let width: number | undefined
let height: number | undefined
let duration = 5
if (isVideo || isAudio) {
const mediaUrl = electronFilePath ? pathToFileUrl(electronFilePath) : URL.createObjectURL(file)
duration = await getMediaDuration(mediaUrl, isAudio)
}
if (electronFilePath) {
if (isVideo || isImage) {
const copied = await addVisualAssetToProject(electronFilePath, currentProjectId, isVideo ? 'video' : 'image')
if (!copied) continue
persistentPath = copied.path
bigThumbnailPath = copied.bigThumbnailPath
smallThumbnailPath = copied.smallThumbnailPath
width = copied.width
height = copied.height
} else if (isAudio) {
const copied = await addGenericAssetToProject(electronFilePath, currentProjectId)
if (copied?.path) persistentPath = copied.path
}
}
const asset: Asset = {
id: `asset-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
type: isVideo ? 'video' : isAudio ? 'audio' : 'image',
path: persistentPath,
bigThumbnailPath,
smallThumbnailPath,
width,
height,
prompt: `Imported: ${file.name}`,
resolution: 'imported',
duration,
createdAt: Date.now(),
}
addAssetToEditor(asset)
}
e.target.value = ''
if (fileInputRef.current && fileInputRef.current !== e.target) {
fileInputRef.current.value = ''
}
}, [addAssetToEditor, currentProjectId, getMediaDuration])
return {
fileInputRef,
handleImportFile,
}
}
@@ -0,0 +1,344 @@
import { useEffect, useRef } from 'react'
import type { TimelineClip } from '../../types/project-model'
import { pathToFileUrl } from '../../lib/file-url'
import {
selectAssets,
selectClipPathFromAssets,
selectClips,
selectCurrentTime,
selectIsPlaying,
selectLiveAssetForClipFromAssets,
selectTracks,
} from './editor-selectors'
import { useEditorGetState, useEditorStore } from './editor-store'
interface UsePlaybackAudioSyncParams {
playbackTimeRef: React.MutableRefObject<number>
}
type AudioEl = HTMLAudioElement & {
__audioPlaying?: boolean
__lastPlayRetry?: number
__intendedPath?: string
__awaitingCanplay?: boolean
}
const PLAYING_LOOKAHEAD_SECONDS = 5
const PLAYING_LOOKBEHIND_SECONDS = 0.5
const PAUSED_LOOKAHEAD_SECONDS = 1
const PAUSED_LOOKBEHIND_SECONDS = 0.25
const PLAYING_PRELOAD_LOOKAHEAD_SECONDS = 3
const PAUSED_PRELOAD_LOOKAHEAD_SECONDS = 0.5
const ACTIVATION_SEEK_TOLERANCE_SECONDS = 0.12
function isAudioSourceClip(clip: TimelineClip): boolean {
return clip.type === 'audio'
}
function clipOverlapsWindow(
clip: TimelineClip,
time: number,
lookBehindSeconds: number,
lookAheadSeconds: number,
): boolean {
const clipStart = clip.startTime
const clipEnd = clip.startTime + clip.duration
return clipEnd >= time - lookBehindSeconds && clipStart <= time + lookAheadSeconds
}
function getClipStartTargetTime(clip: TimelineClip, mediaDuration: number): number {
return clip.reversed
? Math.max(0, mediaDuration - clip.trimEnd)
: Math.max(0, clip.trimStart)
}
export function usePlaybackAudioSync(params: UsePlaybackAudioSyncParams) {
const { playbackTimeRef } = params
const isPlaying = useEditorStore(selectIsPlaying)
const currentTime = useEditorStore(selectCurrentTime)
const assets = useEditorStore(selectAssets)
const clips = useEditorStore(selectClips)
const tracks = useEditorStore(selectTracks)
const getEditorState = useEditorGetState()
const audioElementsRef = useRef<Map<string, AudioEl>>(new Map())
useEffect(() => {
if (!isPlaying) return
let lastTimestamp: number | null = null
let animFrameId = 0
const tick = (timestamp: number) => {
const editorState = getEditorState()
const allClips = selectClips(editorState)
const currentTracks = selectTracks(editorState)
const currentAssets = selectAssets(editorState)
const atTime = playbackTimeRef.current
const audioMap = audioElementsRef.current
if (lastTimestamp === null) {
lastTimestamp = timestamp
}
const activeAudioIds = new Set<string>()
const retainedAudioIds = new Set<string>()
const retainedAudioPaths = new Map<string, string>()
const preloadedAudioIds = new Set<string>()
const anySoloed = currentTracks.some(track => track.solo)
for (const clip of allClips) {
if (!isAudioSourceClip(clip)) continue
if (currentTracks[clip.trackIndex]?.enabled === false) continue
const audioPath = selectClipPathFromAssets(currentAssets, clip)
if (!audioPath) continue
if (clipOverlapsWindow(clip, atTime, PLAYING_LOOKBEHIND_SECONDS, PLAYING_LOOKAHEAD_SECONDS)) {
retainedAudioIds.add(clip.id)
retainedAudioPaths.set(clip.id, audioPath)
}
if (atTime >= clip.startTime && atTime < clip.startTime + clip.duration) {
activeAudioIds.add(clip.id)
}
if (clipOverlapsWindow(clip, atTime, 0, PLAYING_PRELOAD_LOOKAHEAD_SECONDS)) {
preloadedAudioIds.add(clip.id)
}
}
for (const [id, el] of audioMap) {
if (!retainedAudioIds.has(id)) {
el.pause()
el.src = ''
audioMap.delete(id)
continue
}
if (!activeAudioIds.has(id)) {
if (!el.paused) el.pause()
el.__audioPlaying = false
el.preload = preloadedAudioIds.has(id) ? 'auto' : 'metadata'
}
}
for (const clip of allClips) {
if (!retainedAudioIds.has(clip.id)) continue
const audioPath = retainedAudioPaths.get(clip.id)
if (!audioPath) continue
let el = audioMap.get(clip.id)
let isNewElement = false
if (!el) {
el = document.createElement('audio') as AudioEl
el.src = pathToFileUrl(audioPath)
el.__intendedPath = audioPath
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
audioMap.set(clip.id, el)
isNewElement = true
} else if (el.__intendedPath !== audioPath) {
el.src = pathToFileUrl(audioPath)
el.__intendedPath = audioPath
el.__audioPlaying = false
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
isNewElement = true
} else {
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
}
const computeTarget = (audioEl: AudioEl, time: number): number => {
const assetDur = audioEl.duration || clip.duration
const timeInClip = time - clip.startTime
return clip.reversed
? Math.max(0, assetDur - clip.trimEnd - timeInClip * clip.speed)
: Math.max(0, clip.trimStart + timeInClip * clip.speed)
}
const desiredRate = clip.reversed ? 1 : clip.speed
if (el.readyState >= 2) {
if (!activeAudioIds.has(clip.id)) {
if (preloadedAudioIds.has(clip.id)) {
const preloadTarget = getClipStartTargetTime(clip, el.duration || clip.duration)
if (Math.abs(el.currentTime - preloadTarget) > 0.05) {
el.currentTime = preloadTarget
}
}
continue
}
const track = currentTracks[clip.trackIndex]
const isSoloMuted = anySoloed && !track?.solo
el.muted = clip.muted || track?.muted || isSoloMuted || false
el.volume = clip.volume
if (!el.__audioPlaying || isNewElement) {
const target = computeTarget(el, atTime)
if (isNewElement || Math.abs(el.currentTime - target) > ACTIVATION_SEEK_TOLERANCE_SECONDS) {
el.currentTime = target
}
el.playbackRate = desiredRate
if (clip.reversed) {
el.pause()
el.__audioPlaying = false
} else {
el.play().catch(() => {})
el.__audioPlaying = true
el.__lastPlayRetry = 0
}
} else {
if (el.playbackRate !== desiredRate) el.playbackRate = desiredRate
if (clip.reversed) {
if (!el.paused) el.pause()
el.__audioPlaying = false
} else {
const target = computeTarget(el, atTime)
const drift = Math.abs(el.currentTime - target)
if (drift > 1.5) {
el.currentTime = target
}
if (el.paused) {
const lastRetry = el.__lastPlayRetry || 0
if (timestamp - lastRetry > 500) {
el.__lastPlayRetry = timestamp
el.play().catch(() => {})
}
}
}
}
} else if (!el.__awaitingCanplay) {
el.__awaitingCanplay = true
const expectedPath = audioPath
const onCanPlay = () => {
el!.removeEventListener('canplay', onCanPlay)
el!.__awaitingCanplay = false
if (audioElementsRef.current.get(clip.id) !== el) return
if (el!.__intendedPath !== expectedPath) return
const freshTime = playbackTimeRef.current
const activeNow = freshTime >= clip.startTime && freshTime < clip.startTime + clip.duration
const shouldPreload = clipOverlapsWindow(clip, freshTime, 0, PLAYING_PRELOAD_LOOKAHEAD_SECONDS)
if (!selectIsPlaying(getEditorState()) || !activeNow) {
if (shouldPreload) {
const preloadTarget = getClipStartTargetTime(clip, el!.duration || clip.duration)
el!.currentTime = preloadTarget
}
return
}
const target = computeTarget(el!, freshTime)
if (Math.abs(el!.currentTime - target) > ACTIVATION_SEEK_TOLERANCE_SECONDS) {
el!.currentTime = target
}
el!.playbackRate = desiredRate
if (clip.reversed) {
el!.pause()
el!.__audioPlaying = false
} else {
el!.play().catch(() => {})
el!.__audioPlaying = true
el!.__lastPlayRetry = 0
}
}
el.addEventListener('canplay', onCanPlay)
}
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(animFrameId)
for (const [, el] of audioElementsRef.current) {
if (!el.paused) el.pause()
el.__audioPlaying = false
}
}
}, [getEditorState, isPlaying, playbackTimeRef])
useEffect(() => {
if (isPlaying) return
for (const [, el] of audioElementsRef.current) {
if (!el.paused) el.pause()
el.__audioPlaying = false
}
const retainedAudioClips = clips.filter(clip => {
if (!isAudioSourceClip(clip)) return false
if (tracks[clip.trackIndex]?.enabled === false) return false
if (!selectClipPathFromAssets(assets, clip)) return false
return clipOverlapsWindow(clip, currentTime, PAUSED_LOOKBEHIND_SECONDS, PAUSED_LOOKAHEAD_SECONDS)
})
const retainedAudioIds = new Set(retainedAudioClips.map(clip => clip.id))
const preloadedAudioIds = new Set(
retainedAudioClips
.filter(clip => clipOverlapsWindow(clip, currentTime, 0, PAUSED_PRELOAD_LOOKAHEAD_SECONDS))
.map(clip => clip.id),
)
for (const [id, el] of audioElementsRef.current) {
if (!retainedAudioIds.has(id)) {
el.pause()
el.src = ''
audioElementsRef.current.delete(id)
}
}
for (const clip of retainedAudioClips) {
const clipPath = selectClipPathFromAssets(assets, clip)
if (!clipPath) continue
let el = audioElementsRef.current.get(clip.id)
if (!el) {
el = document.createElement('audio') as AudioEl
el.src = pathToFileUrl(clipPath)
el.__intendedPath = clipPath
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
audioElementsRef.current.set(clip.id, el)
} else if (el.__intendedPath !== clipPath) {
el.src = pathToFileUrl(clipPath)
el.__intendedPath = clipPath
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
} else {
el.preload = preloadedAudioIds.has(clip.id) ? 'auto' : 'metadata'
}
const isAtPlayhead = currentTime >= clip.startTime && currentTime < clip.startTime + clip.duration
const liveAsset = selectLiveAssetForClipFromAssets(assets, clip)
const assetDuration = liveAsset?.duration || clip.asset?.duration || clip.duration
const anySoloed = tracks.some(track => track.solo)
const track = tracks[clip.trackIndex]
const isSoloMuted = anySoloed && !track?.solo
el.muted = clip.muted || track?.muted || isSoloMuted || false
el.volume = clip.volume
if (el.readyState < 2) continue
if (!isAtPlayhead) {
if (preloadedAudioIds.has(clip.id)) {
const preloadTarget = getClipStartTargetTime(clip, assetDuration)
if (Math.abs(el.currentTime - preloadTarget) > 0.05) {
el.currentTime = preloadTarget
}
}
continue
}
const timeInClip = currentTime - clip.startTime
const targetTime = clip.reversed
? Math.max(0, assetDuration - clip.trimEnd - timeInClip * clip.speed)
: Math.max(0, clip.trimStart + timeInClip * clip.speed)
if (Math.abs(el.currentTime - targetTime) > 0.05) {
el.currentTime = targetTime
}
}
}, [assets, clips, currentTime, isPlaying, tracks])
useEffect(() => {
return () => {
for (const [, el] of audioElementsRef.current) {
el.pause()
el.src = ''
}
audioElementsRef.current.clear()
}
}, [])
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import {
selectActiveTimelineInPoint,
selectActiveTimelineOutPoint,
selectIsPlaying,
selectPlayingInOut,
selectShuttleSpeed,
selectTotalDuration,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
const STATE_UPDATE_INTERVAL_MS = 250
export interface UsePlaybackEngineParams {
playbackTimeRef: React.MutableRefObject<number>
}
export function usePlaybackEngine(params: UsePlaybackEngineParams) {
const {
playbackTimeRef,
} = params
const { setCurrentTime, setPlayingInOut, stopShuttle } = useEditorActions()
const isPlaying = useEditorStore(selectIsPlaying)
const shuttleSpeed = useEditorStore(selectShuttleSpeed)
const playingInOut = useEditorStore(selectPlayingInOut)
const inPoint = useEditorStore(selectActiveTimelineInPoint)
const outPoint = useEditorStore(selectActiveTimelineOutPoint)
const totalDuration = useEditorStore(selectTotalDuration)
const lastStateUpdateRef = useRef(0)
const prevIsPlayingRef = useRef(isPlaying)
// Flush transport time immediately when playback stops to avoid a one-frame
// visual jump where paused UI reads stale currentTime before effect cleanup runs.
useLayoutEffect(() => {
const wasPlaying = prevIsPlayingRef.current
prevIsPlayingRef.current = isPlaying
if (wasPlaying && !isPlaying) {
setCurrentTime(playbackTimeRef.current)
}
}, [isPlaying, playbackTimeRef, setCurrentTime])
useEffect(() => {
if (!isPlaying) return
const effectiveSpeed = shuttleSpeed !== 0 ? shuttleSpeed : 1
let lastTimestamp: number | null = null
let animFrameId = 0
lastStateUpdateRef.current = 0
const tick = (timestamp: number) => {
if (lastTimestamp === null) {
lastTimestamp = timestamp
lastStateUpdateRef.current = timestamp
}
const deltaMs = timestamp - lastTimestamp
lastTimestamp = timestamp
const deltaSec = (deltaMs / 1000) * effectiveSpeed
let next = playbackTimeRef.current + deltaSec
let stopped = false
if (playingInOut && inPoint !== null && outPoint !== null) {
const loopStart = Math.min(inPoint, outPoint)
const loopEnd = Math.max(inPoint, outPoint)
if (next >= loopEnd) next = loopStart
else if (next <= loopStart) next = loopEnd
} else {
if (next >= totalDuration) {
next = 0
stopped = true
} else if (next < 0) {
next = 0
stopped = true
}
}
playbackTimeRef.current = next
if (stopped) {
stopShuttle()
setCurrentTime(next)
return
}
if (timestamp - lastStateUpdateRef.current >= STATE_UPDATE_INTERVAL_MS) {
lastStateUpdateRef.current = timestamp
setCurrentTime(next)
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(animFrameId)
setCurrentTime(playbackTimeRef.current)
}
}, [
isPlaying,
shuttleSpeed,
playingInOut,
inPoint,
outPoint,
totalDuration,
playbackTimeRef,
setCurrentTime,
stopShuttle,
])
useEffect(() => {
if (!isPlaying && playingInOut) {
setPlayingInOut(false)
}
}, [isPlaying, playingInOut, setPlayingInOut])
}
+379
View File
@@ -0,0 +1,379 @@
import { useCallback, useEffect, useRef } from 'react'
import type { Asset, TimelineClip } from '../../types/project-model'
import type { GenerationSettings } from '../../components/SettingsPanel'
import type { GenerationError } from '../../lib/generation-errors'
import type { VideoGenerationPipeline } from '../../lib/video-generation-model-specs'
import type { GenSpaceMode } from '../../lib/genspace-multi-keyframe'
import { fromPersistedKeyframes, type KeyframeItem } from '../../lib/multi-keyframe'
import { addVisualAssetToProject } from '../../lib/asset-copy'
import { ApiClient } from '../../lib/api-client'
import { logger } from '../../lib/logger'
import {
selectAssets,
selectClips,
selectRegenerationPreError,
selectRegeneratingAssetId,
selectRegeneratingClipId,
} from './editor-selectors'
import { useEditorActions, useEditorStore } from './editor-store'
const LEGACY_VIDEO_RESOLUTION_MAP: Record<string, string> = {
'768x512': '540p',
'1024x576': '540p',
'1280x720': '720p',
'1920x1080': '1080p',
}
const LEGACY_IMAGE_RESOLUTION_MAP: Record<string, string> = {
'768x512': '1080p',
'1024x576': '1080p',
'1280x720': '1080p',
'1920x1080': '1080p',
'2160p': '2048p',
}
function normalizeVideoResolution(value: string | undefined): string {
if (!value) return '540p'
if (value === '540p' || value === '720p' || value === '1080p' || value === '1440p' || value === '2160p') {
return value
}
return LEGACY_VIDEO_RESOLUTION_MAP[value] || '540p'
}
function normalizeImageResolution(value: string | undefined): string {
if (!value) return '1080p'
if (value === '1080p' || value === '1440p' || value === '2048p') {
return value
}
return LEGACY_IMAGE_RESOLUTION_MAP[value] || '1080p'
}
function resolveLiveAssetForClip(assets: Asset[], clip: TimelineClip): Asset {
if (!clip.assetId) return clip.asset!
return assets.find(asset => asset.id === clip.assetId) || clip.asset!
}
function resolveClipPath(assets: Asset[], clip: TimelineClip): string {
const liveAsset = resolveLiveAssetForClip(assets, clip)
if (liveAsset.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
return liveAsset.takes[idx].path || ''
}
return liveAsset.path || ''
}
function resolveAssetPreviewPath(assets: Asset[], clips: TimelineClip[], asset: Asset, clipId?: string): string {
const clip = clipId ? clips.find(candidate => candidate.id === clipId) : undefined
if (clip) {
const clipPath = resolveClipPath(assets, clip)
if (clipPath) return clipPath
}
if (asset.takes && asset.takes.length > 0) {
const activeIndex = Math.max(0, Math.min(asset.activeTakeIndex ?? (asset.takes.length - 1), asset.takes.length - 1))
return asset.takes[activeIndex]?.path || asset.path
}
return asset.path
}
export interface UseRegenerationParams {
projectId: string
// Generation hook values
regenGenerate: (
prompt: string,
imagePath: string | null,
settings: GenerationSettings,
audioPath?: string | null,
lastImagePath?: string | null,
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
) => Promise<void>
regenGenerateImage: (prompt: string, settings: GenerationSettings, editSource?: string | null) => Promise<void>
regenVideoPath: string | null
regenImagePath: string | null
isRegenerating: boolean
regenCancel: () => void
regenReset: () => void
regenError: GenerationError | null
canCancelInFlight: boolean
shouldVideoGenerateWithLtxApi: boolean
}
export function useRegeneration(params: UseRegenerationParams) {
const {
projectId,
regenGenerate,
regenGenerateImage,
regenVideoPath,
regenImagePath,
isRegenerating,
regenCancel,
regenReset,
regenError,
canCancelInFlight,
shouldVideoGenerateWithLtxApi,
} = params
const {
applyGeneratedTake,
cancelClipRegeneration,
failClipRegeneration,
setRegenerationPreError,
startClipRegeneration,
updateAsset,
} = useEditorActions()
const assets = useEditorStore(selectAssets)
const clips = useEditorStore(selectClips)
const regeneratingAssetId = useEditorStore(selectRegeneratingAssetId)
const regeneratingClipId = useEditorStore(selectRegeneratingClipId)
const regenerationPreError = useEditorStore(selectRegenerationPreError)
const cancelRequestedRef = useRef(false)
const dismissRegenerationPreError = useCallback(() => {
setRegenerationPreError(null)
}, [setRegenerationPreError])
const handleRegenerate = useCallback(async (assetId: string, clipId?: string) => {
if (!projectId || isRegenerating) return
const asset = assets.find(candidate => candidate.id === assetId)
if (!asset) return
startClipRegeneration(assetId, clipId)
let generationParams = asset.generationParams
if (!generationParams) {
try {
const clipPath = resolveAssetPreviewPath(assets, clips, asset, clipId)
let framePath = ''
if (asset.type === 'video' && clipPath) {
const frame = await window.electronAPI.extractVideoFrame({
videoPath: clipPath,
seekTime: 0.1,
width: 512,
quality: 3,
})
framePath = frame.path
} else if (asset.type === 'image' && clipPath) {
framePath = clipPath
}
if (framePath) {
const result = await ApiClient.suggestGapPrompt({
gapDuration: asset.duration || 5,
mode: asset.type === 'image' ? 'text-to-image' : 'text-to-video',
beforePrompt: '',
afterPrompt: '',
beforeFrame: framePath,
afterFrame: '',
})
if (!result.ok) {
throw new Error(result.error.message)
}
const promptSuggestion = result.data
if (promptSuggestion.suggested_prompt) {
generationParams = {
mode: asset.type === 'image' ? 'text-to-image' : 'text-to-video',
prompt: promptSuggestion.suggested_prompt,
model: 'fast',
duration: asset.duration || 5,
resolution: asset.type === 'image'
? normalizeImageResolution(asset.resolution)
: normalizeVideoResolution(asset.resolution),
fps: 24,
audio: false,
cameraMotion: 'none',
}
updateAsset(asset.id, { generationParams })
}
}
} catch (error) {
logger.warn(`Failed to auto-generate prompt for imported asset: ${error}`)
}
if (!generationParams) {
failClipRegeneration(
'Could not auto-generate a prompt for this clip. Try using "Send to GenSpace" instead.',
)
return
}
}
if (generationParams.mode === 'retake') {
failClipRegeneration(
'Retake assets cannot be regenerated yet. Try using Retake from the clip menu instead.',
)
return
}
if (generationParams.mode === 'ic-lora') {
failClipRegeneration(
'IC-LoRA assets cannot be regenerated yet. Try using IC-LoRA from the clip menu instead.',
)
return
}
if (generationParams.mode === 'text-to-image') {
void regenGenerateImage(generationParams.prompt, {
model: generationParams.model as VideoGenerationPipeline,
duration: generationParams.duration,
videoResolution: '540p',
fps: generationParams.fps,
audio: generationParams.audio,
cameraMotion: generationParams.cameraMotion,
imageResolution: normalizeImageResolution(generationParams.resolution),
imageAspectRatio: generationParams.imageAspectRatio || '16:9',
imageSteps: generationParams.imageSteps || 4,
variations: 1,
})
return
}
if (generationParams.mode === 'image-edit') {
if (!generationParams.inputImageUrl) {
failClipRegeneration(
'This edited asset is missing its source image and cannot be regenerated.',
)
return
}
void regenGenerateImage(generationParams.prompt, {
model: generationParams.model as VideoGenerationPipeline,
duration: generationParams.duration,
videoResolution: '540p',
fps: generationParams.fps,
audio: generationParams.audio,
cameraMotion: generationParams.cameraMotion,
imageResolution: normalizeImageResolution(generationParams.resolution),
imageAspectRatio: generationParams.imageAspectRatio || '16:9',
imageSteps: generationParams.imageSteps || 8,
imageEditStrength: generationParams.imageEditStrength,
variations: 1,
}, generationParams.inputImageUrl)
return
}
const imagePath = generationParams.inputImageUrl
&& (generationParams.mode === 'image-to-video' || generationParams.mode === 'audio-to-video')
? generationParams.inputImageUrl
: null
const rawVideoSettings: GenerationSettings = {
model: generationParams.model as VideoGenerationPipeline,
duration: generationParams.duration,
videoResolution: normalizeVideoResolution(generationParams.resolution),
fps: generationParams.fps,
audio: generationParams.audio,
cameraMotion: generationParams.cameraMotion,
imageResolution: '1080p',
imageAspectRatio: generationParams.imageAspectRatio || '16:9',
imageSteps: generationParams.imageSteps || 4,
// Local LoRA refs are filesystem paths the cloud API can't resolve.
loras: !shouldVideoGenerateWithLtxApi ? generationParams.loras : undefined,
}
void regenGenerate(
generationParams.prompt,
imagePath,
rawVideoSettings,
generationParams.mode === 'audio-to-video' ? generationParams.inputAudioUrl : undefined,
imagePath && generationParams.duration != null ? generationParams.inputLastImageUrl : undefined,
generationParams.mode === 'multi-keyframe'
? {
mode: 'multi-keyframe',
keyframes: fromPersistedKeyframes(generationParams.keyframes ?? []),
}
: undefined,
)
}, [
isRegenerating,
projectId,
regenGenerate,
regenGenerateImage,
startClipRegeneration,
failClipRegeneration,
assets,
clips,
updateAsset,
shouldVideoGenerateWithLtxApi,
])
const handleCancelRegeneration = useCallback(() => {
if (!canCancelInFlight) return
cancelRequestedRef.current = true
regenCancel()
}, [canCancelInFlight, regenCancel])
const persistGeneratedTake = useCallback(async (
generatedPath: string,
type: 'video' | 'image',
assetId: string,
clipId: string | null,
) => {
if (!projectId) return
const copied = await addVisualAssetToProject(generatedPath, projectId, type)
if (!copied) {
logger.error(`Failed to persist regenerated ${type}: ${generatedPath}`)
cancelClipRegeneration()
regenReset()
return
}
applyGeneratedTake(assetId, {
path: copied.path,
bigThumbnailPath: copied.bigThumbnailPath,
smallThumbnailPath: copied.smallThumbnailPath,
width: copied.width,
height: copied.height,
createdAt: Date.now(),
}, clipId ?? undefined)
regenReset()
}, [applyGeneratedTake, cancelClipRegeneration, projectId, regenReset])
useEffect(() => {
if (cancelRequestedRef.current) return
if (!regenVideoPath || !regeneratingAssetId || !projectId || isRegenerating) return
void persistGeneratedTake(regenVideoPath, 'video', regeneratingAssetId, regeneratingClipId)
}, [
isRegenerating,
persistGeneratedTake,
projectId,
regeneratingAssetId,
regeneratingClipId,
regenVideoPath,
])
useEffect(() => {
if (cancelRequestedRef.current) return
if (!regenImagePath || !regeneratingAssetId || !projectId || isRegenerating) return
void persistGeneratedTake(regenImagePath, 'image', regeneratingAssetId, regeneratingClipId)
}, [
isRegenerating,
persistGeneratedTake,
projectId,
regeneratingAssetId,
regeneratingClipId,
regenImagePath,
])
// Keep UI clip state in sync if generation fails.
// Do not reset generation error here; dialog owns that lifecycle.
useEffect(() => {
if (cancelRequestedRef.current) return
if (!regeneratingAssetId || isRegenerating || !regenError) return
cancelClipRegeneration()
}, [cancelClipRegeneration, isRegenerating, regeneratingAssetId, regenError])
useEffect(() => {
if (!cancelRequestedRef.current || isRegenerating) return
cancelRequestedRef.current = false
cancelClipRegeneration()
regenReset()
}, [cancelClipRegeneration, isRegenerating, regenReset])
return {
regeneratingAssetId,
regenerationPreError,
handleRegenerate,
handleCancelRegeneration,
dismissRegenerationPreError,
}
}
@@ -0,0 +1,71 @@
import { useCallback, useRef } from 'react'
import { selectActiveTimeline, selectSubtitles } from './editor-selectors'
import { parseSrt, exportSrt } from '../../lib/srt'
import { useEditorActions, useEditorStore } from './editor-store'
export function useSubtitleImportExport() {
const { importSrtCues } = useEditorActions()
const subtitles = useEditorStore(selectSubtitles)
const activeTimelineName = useEditorStore(state => selectActiveTimeline(state)?.name || 'timeline')
const subtitleFileInputRef = useRef<HTMLInputElement>(null)
const handleImportSrt = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = event => {
const content = event.target?.result as string
if (!content) return
const cues = parseSrt(content)
importSrtCues(cues)
}
reader.readAsText(file)
e.target.value = ''
if (subtitleFileInputRef.current && subtitleFileInputRef.current !== e.target) {
subtitleFileInputRef.current.value = ''
}
}, [importSrtCues])
const handleExportSrt = useCallback(() => {
const cues = subtitles
.filter(subtitle => subtitle.text.trim())
.sort((a, b) => a.startTime - b.startTime)
if (cues.length === 0) {
alert('No subtitles to export')
return
}
const srtContent = exportSrt(cues)
const timelineName = activeTimelineName
if (window.electronAPI?.showSaveDialog) {
window.electronAPI.showSaveDialog({
title: 'Export Subtitles',
defaultPath: `subtitles_${timelineName}.srt`,
filters: [{ name: 'SRT Files', extensions: ['srt'] }],
}).then(filePath => {
if (filePath) {
window.electronAPI!.saveFile({ filePath, data: srtContent })
}
})
return
}
const blob = new Blob([srtContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `subtitles_${timelineName}.srt`
anchor.click()
URL.revokeObjectURL(url)
}, [activeTimelineName, subtitles])
return {
subtitleFileInputRef,
handleImportSrt,
handleExportSrt,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import { useCallback } from 'react'
import type { Asset, TimelineClip } from '../../types/project-model'
import {
selectActiveTimeline,
selectAssets,
selectClips,
} from './editor-selectors'
import { exportFcp7Xml } from '../../lib/timeline-import'
import { useEditorStore } from './editor-store'
interface TimelineXmlExportClip {
name: string
filePath: string
trackIndex: number
type: 'video' | 'audio' | 'image'
startTime: number
duration: number
trimStart: number
sourceDuration: number
width?: number
height?: number
}
function parseExportDimensions(resolution?: string): { width: number; height: number } {
const match = resolution?.match(/(\d+)x(\d+)/)
if (!match) return { width: 1920, height: 1080 }
return {
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
}
}
function resolveLiveAsset(assets: Asset[], clip: TimelineClip): Asset {
if (!clip.assetId) return clip.asset!
return assets.find(asset => asset.id === clip.assetId) || clip.asset!
}
function buildTimelineXmlExportClips(clips: TimelineClip[], assets: Asset[]): TimelineXmlExportClip[] {
return clips
.filter(clip => clip.asset && clip.type !== 'adjustment')
.map(clip => {
const asset = resolveLiveAsset(assets, clip)
let filePath = asset.path
if (asset.takes && asset.takes.length > 0) {
const takeIndex = clip.takeIndex ?? asset.activeTakeIndex ?? 0
const take = asset.takes[Math.min(takeIndex, asset.takes.length - 1)]
filePath = take.path
}
const { width, height } = parseExportDimensions(asset.resolution)
return {
name: clip.importedName || asset.path?.split(/[/\\]/).pop() || 'clip',
filePath,
trackIndex: clip.trackIndex,
type: clip.type as 'video' | 'image' | 'audio',
startTime: clip.startTime,
duration: clip.duration,
trimStart: clip.trimStart,
sourceDuration: asset.duration || clip.duration,
width,
height,
}
})
}
export function useTimelineXmlExport() {
const activeTimeline = useEditorStore(selectActiveTimeline)
const clips = useEditorStore(selectClips)
const assets = useEditorStore(selectAssets)
const handleExportTimelineXml = useCallback(async () => {
const timeline = activeTimeline
if (!timeline) return
const xmlContent = exportFcp7Xml({
name: timeline.name,
fps: 24,
width: 1920,
height: 1080,
clips: buildTimelineXmlExportClips(clips, assets),
})
if (window.electronAPI?.showSaveDialog) {
const filePath = await window.electronAPI.showSaveDialog({
title: 'Export Timeline as FCP 7 XML',
defaultPath: `${timeline.name}.xml`,
filters: [{ name: 'FCP 7 XML', extensions: ['xml'] }],
})
if (filePath) {
await window.electronAPI.saveFile({ filePath, data: xmlContent })
}
return
}
const blob = new Blob([xmlContent], { type: 'text/xml' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${timeline.name}.xml`
anchor.click()
URL.revokeObjectURL(url)
}, [activeTimeline, assets, clips])
return {
handleExportTimelineXml,
}
}
+613
View File
@@ -0,0 +1,613 @@
import React from 'react'
import {
MousePointer2, ChevronRight, Scissors,
ArrowLeftRight, GitMerge, MoveHorizontal, Gauge,
} from 'lucide-react'
import { formatKeyCombo, type ActionId, type KeyboardLayout } from '../../lib/keyboard-shortcuts'
export type { KeyboardLayout } from '../../lib/keyboard-shortcuts'
import type { TimelineClip, TransitionType, Track, ClipEffect, EffectMask } from '../../types/project-model'
import { DEFAULT_COLOR_CORRECTION } from '../../types/project-model'
// ── Tool types & definitions ────────────────────────────────────────
export type ToolType = 'select' | 'trackForward' | 'blade' | 'slip' | 'slide' | 'ripple' | 'roll'
export type ToolDef = { id: ToolType; icon: any; label: string; actionId: ActionId }
export const PRIMARY_TOOLS: ToolDef[] = [
{ id: 'select', icon: MousePointer2, label: 'Selection Tool', actionId: 'tool.select' },
{ id: 'trackForward', icon: ChevronRight, label: 'Track Select Forward (Shift: single track)', actionId: 'tool.trackForward' },
{ id: 'blade', icon: Scissors, label: 'Blade Tool', actionId: 'tool.blade' },
]
export const TRIM_TOOLS: ToolDef[] = [
{ id: 'ripple', icon: ArrowLeftRight, label: 'Ripple Trim', actionId: 'tool.ripple' },
{ id: 'roll', icon: GitMerge, label: 'Roll Trim (A/B)', actionId: 'tool.roll' },
{ id: 'slip', icon: MoveHorizontal, label: 'Slip Tool', actionId: 'tool.slip' },
{ id: 'slide', icon: Gauge, label: 'Slide Tool', actionId: 'tool.slide' },
]
// ── Color Labels (Premiere-style) ────────────────────────────────────
export interface ColorLabelDef {
id: string
label: string
color: string // Tailwind-friendly hex for rendering
bg: string // Background class for timeline clips
border: string // Border class for timeline clips
dot: string // Dot color class for menus
}
export const COLOR_LABELS: ColorLabelDef[] = [
{ id: 'violet', label: 'Violet', color: '#8b5cf6', bg: 'bg-violet-700/50', border: 'border-violet-500', dot: 'bg-violet-500' },
{ id: 'blue', label: 'Blue', color: '#3b82f6', bg: 'bg-blue-700/50', border: 'border-blue-500', dot: 'bg-blue-500' },
{ id: 'cyan', label: 'Cyan', color: '#06b6d4', bg: 'bg-cyan-700/50', border: 'border-cyan-500', dot: 'bg-cyan-500' },
{ id: 'teal', label: 'Teal', color: '#14b8a6', bg: 'bg-teal-700/50', border: 'border-teal-500', dot: 'bg-teal-500' },
{ id: 'green', label: 'Green', color: '#22c55e', bg: 'bg-green-700/50', border: 'border-green-500', dot: 'bg-green-500' },
{ id: 'yellow', label: 'Yellow', color: '#eab308', bg: 'bg-yellow-700/50', border: 'border-yellow-500', dot: 'bg-yellow-500' },
{ id: 'orange', label: 'Orange', color: '#f97316', bg: 'bg-orange-700/50', border: 'border-orange-500', dot: 'bg-orange-500' },
{ id: 'red', label: 'Red', color: '#ef4444', bg: 'bg-red-700/50', border: 'border-red-500', dot: 'bg-red-500' },
{ id: 'rose', label: 'Rose', color: '#f43f5e', bg: 'bg-rose-700/50', border: 'border-rose-500', dot: 'bg-rose-500' },
{ id: 'pink', label: 'Pink', color: '#ec4899', bg: 'bg-pink-700/50', border: 'border-pink-500', dot: 'bg-pink-500' },
]
export function getColorLabel(id: string | undefined): ColorLabelDef | undefined {
if (!id) return undefined
return COLOR_LABELS.find(c => c.id === id)
}
// ── Constants ────────────────────────────────────────────────────────
/** Debounce delay for auto-saving timeline changes to context (ms) */
export const AUTOSAVE_DELAY = 500
/** Tolerance in seconds for detecting adjacent clips (cut points) */
export const CUT_POINT_TOLERANCE = 0.05
/** Default cross-dissolve duration in seconds */
export const DEFAULT_DISSOLVE_DURATION = 0.5
// ── Resizable layout constants ───────────────────────────────────────
export const LAYOUT_STORAGE_KEY = 'ltx-video-editor-layout'
export interface EditorLayout {
leftPanelWidth: number // px
rightPanelWidth: number // px
timelineHeight: number // px
assetsHeight: number // px – height of assets section in left panel (timelines gets the rest)
}
export const DEFAULT_LAYOUT: EditorLayout = {
leftPanelWidth: 288, // w-72
rightPanelWidth: 256, // w-64
timelineHeight: 224, // h-56
assetsHeight: 0, // 0 = auto (use flex proportions)
}
export const LAYOUT_LIMITS = {
leftPanelWidth: { min: 180, max: 480 },
rightPanelWidth: { min: 200, max: 480 },
timelineHeight: { min: 120, max: 600 },
assetsHeight: { min: 120, max: 800 },
}
// ── Pure helper functions ────────────────────────────────────────────
/** Get the display shortcut string for an action from the active layout */
export function getShortcutLabel(layout: KeyboardLayout, actionId: ActionId): string {
const combos = layout[actionId]
if (!combos || combos.length === 0) return ''
return formatKeyCombo(combos[0])
}
/** Returns `"label (shortcut)"` when a shortcut exists, or just `"label"` when it doesn't. */
export function tooltipLabel(label: string, shortcut: string): string {
return shortcut ? `${label} (${shortcut})` : label
}
/**
* Overwrite helper: given a moved/placed clip, trim or split any clips
* on the same track that it overlaps. Returns the updated clips array.
* `movedIds` = IDs of the clip(s) being moved (they should not be trimmed).
*/
export function resolveOverlaps(
allClips: TimelineClip[],
movedIds: Set<string>,
): TimelineClip[] {
let result = [...allClips]
for (const movedId of movedIds) {
const moved = result.find(c => c.id === movedId)
if (!moved) continue
const movedStart = moved.startTime
const movedEnd = moved.startTime + moved.duration
const next: TimelineClip[] = []
for (const c of result) {
if (movedIds.has(c.id)) { next.push(c); continue }
if (c.trackIndex !== moved.trackIndex) { next.push(c); continue }
const cStart = c.startTime
const cEnd = c.startTime + c.duration
if (cEnd <= movedStart || cStart >= movedEnd) { next.push(c); continue }
if (cStart >= movedStart && cEnd <= movedEnd) continue
if (cStart < movedStart && cEnd > movedStart && cEnd <= movedEnd) {
const newDuration = movedStart - cStart
next.push({ ...c, duration: newDuration })
continue
}
if (cStart >= movedStart && cStart < movedEnd && cEnd > movedEnd) {
const trimAmount = movedEnd - cStart
const newTrimStart = c.trimStart + trimAmount * c.speed
next.push({
...c,
startTime: movedEnd,
duration: c.duration - trimAmount,
trimStart: newTrimStart,
})
continue
}
if (cStart < movedStart && cEnd > movedEnd) {
const leftDuration = movedStart - cStart
next.push({ ...c, duration: leftDuration })
const rightTrimAmount = (movedEnd - cStart) * c.speed
const rightDuration = cEnd - movedEnd
next.push({
...c,
id: `${c.id}-split-${Date.now()}`,
startTime: movedEnd,
duration: rightDuration,
trimStart: c.trimStart + rightTrimAmount,
})
continue
}
next.push(c)
}
result = next
}
return result
}
export function loadLayout(): EditorLayout {
try {
const stored = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return {
leftPanelWidth: clampVal(parsed.leftPanelWidth ?? DEFAULT_LAYOUT.leftPanelWidth, LAYOUT_LIMITS.leftPanelWidth),
rightPanelWidth: clampVal(parsed.rightPanelWidth ?? DEFAULT_LAYOUT.rightPanelWidth, LAYOUT_LIMITS.rightPanelWidth),
timelineHeight: clampVal(parsed.timelineHeight ?? DEFAULT_LAYOUT.timelineHeight, LAYOUT_LIMITS.timelineHeight),
assetsHeight: parsed.assetsHeight ? clampVal(parsed.assetsHeight, LAYOUT_LIMITS.assetsHeight) : 0,
}
}
} catch { /* ignore */ }
return { ...DEFAULT_LAYOUT }
}
export function saveLayout(layout: EditorLayout) {
try { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout)) } catch { /* ignore */ }
}
// ── Layout presets ──────────────────────────────────────────────────
export const LAYOUT_PRESETS_KEY = 'ltx-video-editor-layout-presets'
export interface LayoutPreset {
id: string
name: string
layout: EditorLayout
}
export function loadLayoutPresets(): LayoutPreset[] {
try {
const stored = localStorage.getItem(LAYOUT_PRESETS_KEY)
if (stored) return JSON.parse(stored) as LayoutPreset[]
} catch { /* ignore */ }
return []
}
export function saveLayoutPresets(presets: LayoutPreset[]) {
try { localStorage.setItem(LAYOUT_PRESETS_KEY, JSON.stringify(presets)) } catch { /* ignore */ }
}
export function clampVal(val: number, limits: { min: number; max: number }): number {
return Math.max(limits.min, Math.min(limits.max, val))
}
/** Migrate old clips that don't have new effect fields */
export function migrateClip(clip: TimelineClip): TimelineClip {
return {
...clip,
flipH: clip.flipH ?? false,
flipV: clip.flipV ?? false,
transitionIn: clip.transitionIn ?? { type: 'none', duration: 0.5 },
transitionOut: clip.transitionOut ?? { type: 'none', duration: 0.5 },
colorCorrection: clip.colorCorrection ?? { ...DEFAULT_COLOR_CORRECTION },
opacity: clip.opacity ?? 100,
isRegenerating: false,
}
}
/**
* Migrate tracks from old format (no kind) to new NLE layout.
* Heuristic: if a track has no `kind`, infer from its name or position.
*/
export function migrateTracks(tracks: Track[]): Track[] {
return tracks.map(t => {
if (t.kind) return t
if (t.type === 'subtitle') return t
if (/^A\d/i.test(t.name)) return { ...t, kind: 'audio' as const }
if (/^V\d/i.test(t.name)) return { ...t, kind: 'video' as const }
return { ...t, kind: 'video' as const }
})
}
/** Build CSS filter + transform strings from clip effects */
export function getClipEffectStyles(clip: TimelineClip, timeInClip?: number): React.CSSProperties {
const cc = clip.colorCorrection || DEFAULT_COLOR_CORRECTION
const filters: string[] = []
if (cc.brightness !== 0) filters.push(`brightness(${1 + cc.brightness / 100})`)
if (cc.contrast !== 0) filters.push(`contrast(${1 + cc.contrast / 100})`)
if (cc.saturation !== 0) filters.push(`saturate(${1 + cc.saturation / 100})`)
if (cc.exposure !== 0) filters.push(`brightness(${1 + cc.exposure / 200})`)
if (cc.temperature !== 0) {
const t = cc.temperature
if (t > 0) {
filters.push(`sepia(${t / 200})`)
filters.push(`hue-rotate(-${t * 0.1}deg)`)
} else {
filters.push(`hue-rotate(${Math.abs(t) * 0.4}deg)`)
}
}
if (cc.tint !== 0) {
filters.push(`hue-rotate(${cc.tint * 1.2}deg)`)
}
if (cc.highlights !== 0) filters.push(`brightness(${1 + cc.highlights / 300})`)
if (cc.shadows !== 0) filters.push(`contrast(${1 + cc.shadows / 300})`)
/* EFFECTS HIDDEN - skip effects iteration because effects are not applied during export
if (clip.effects) {
for (const fx of clip.effects) {
if (!fx.enabled) continue
if (fx.mask?.enabled) continue // masked effects rendered as separate overlay
const p = fx.params
switch (fx.type) {
case 'blur':
if (p.amount > 0) filters.push(`blur(${p.amount}px)`)
break
case 'sharpen': {
const s = p.amount / 100
if (s > 0) filters.push(`contrast(${1 + s * 0.3})`)
break
}
case 'glow': {
const intensity = p.amount / 100
const radius = p.radius || 10
if (intensity > 0) {
filters.push(`brightness(${1 + intensity * 0.3})`)
filters.push(`drop-shadow(0 0 ${radius * intensity}px rgba(255,255,255,${intensity * 0.4}))`)
}
break
}
case 'vignette':
case 'grain':
break
case 'lut-cinematic': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`contrast(${1 + 0.1 * t})`)
filters.push(`saturate(${1 - 0.15 * t})`)
filters.push(`sepia(${0.15 * t})`)
filters.push(`brightness(${1 - 0.05 * t})`)
}
break
}
case 'lut-vintage': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`sepia(${0.4 * t})`)
filters.push(`contrast(${1 - 0.1 * t})`)
filters.push(`saturate(${1 - 0.2 * t})`)
filters.push(`brightness(${1 + 0.05 * t})`)
}
break
}
case 'lut-bw': {
const t = (p.intensity ?? 100) / 100
if (t > 0) filters.push(`grayscale(${t})`)
break
}
case 'lut-cool': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`hue-rotate(${10 * t}deg)`)
filters.push(`saturate(${1 - 0.1 * t})`)
filters.push(`brightness(${1 + 0.02 * t})`)
}
break
}
case 'lut-warm': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`sepia(${0.2 * t})`)
filters.push(`hue-rotate(${-5 * t}deg)`)
filters.push(`saturate(${1 + 0.1 * t})`)
}
break
}
case 'lut-muted': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`saturate(${1 - 0.5 * t})`)
filters.push(`contrast(${1 - 0.05 * t})`)
}
break
}
case 'lut-vivid': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`saturate(${1 + 0.6 * t})`)
filters.push(`contrast(${1 + 0.1 * t})`)
}
break
}
}
}
}
EFFECTS HIDDEN */
const transforms: string[] = []
if (clip.flipH) transforms.push('scaleX(-1)')
if (clip.flipV) transforms.push('scaleY(-1)')
let opacity = (clip.opacity ?? 100) / 100
if (timeInClip !== undefined) {
const tIn = clip.transitionIn
const tOut = clip.transitionOut
if (tIn && tIn.duration > 0 && timeInClip < tIn.duration) {
if (tIn.type === 'fade-to-black' || tIn.type === 'fade-to-white') {
opacity = Math.min(opacity, timeInClip / tIn.duration)
}
}
if (tOut && tOut.duration > 0) {
const timeFromEnd = clip.duration - timeInClip
if (timeFromEnd < tOut.duration) {
if (tOut.type === 'fade-to-black' || tOut.type === 'fade-to-white') {
opacity = Math.min(opacity, timeFromEnd / tOut.duration)
}
}
}
}
let clipPath: string | undefined
if (timeInClip !== undefined) {
const tIn = clip.transitionIn
const tOut = clip.transitionOut
if (tIn && tIn.type.startsWith('wipe-') && tIn.duration > 0 && timeInClip < tIn.duration) {
const progress = timeInClip / tIn.duration
clipPath = getWipeClipPath(tIn.type as TransitionType, progress, true)
}
if (tOut && tOut.type.startsWith('wipe-') && tOut.duration > 0) {
const timeFromEnd = clip.duration - timeInClip
if (timeFromEnd < tOut.duration) {
const progress = timeFromEnd / tOut.duration
clipPath = getWipeClipPath(tOut.type as TransitionType, progress, false)
}
}
}
const style: React.CSSProperties = {}
if (filters.length > 0) style.filter = filters.join(' ')
if (transforms.length > 0) style.transform = transforms.join(' ')
if (opacity < 1) style.opacity = opacity
if (clipPath) style.clipPath = clipPath
return style
}
// ── Effect Mask Utilities ──────────────────────────────────────────────
/** Get the CSS filter string for a single effect */
export function getSingleEffectFilter(fx: ClipEffect): string {
if (!fx.enabled) return ''
const p = fx.params
const filters: string[] = []
switch (fx.type) {
case 'blur':
if (p.amount > 0) filters.push(`blur(${p.amount}px)`)
break
case 'sharpen': {
const s = p.amount / 100
if (s > 0) filters.push(`contrast(${1 + s * 0.3})`)
break
}
case 'glow': {
const intensity = p.amount / 100
const radius = p.radius || 10
if (intensity > 0) {
filters.push(`brightness(${1 + intensity * 0.3})`)
filters.push(`drop-shadow(0 0 ${radius * intensity}px rgba(255,255,255,${intensity * 0.4}))`)
}
break
}
case 'lut-cinematic': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`contrast(${1 + 0.1 * t})`); filters.push(`saturate(${1 - 0.15 * t})`); filters.push(`sepia(${0.15 * t})`); filters.push(`brightness(${1 - 0.05 * t})`) }
break
}
case 'lut-vintage': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`sepia(${0.4 * t})`); filters.push(`contrast(${1 - 0.1 * t})`); filters.push(`saturate(${1 - 0.2 * t})`); filters.push(`brightness(${1 + 0.05 * t})`) }
break
}
case 'lut-bw': {
const t = (p.intensity ?? 100) / 100
if (t > 0) filters.push(`grayscale(${t})`)
break
}
case 'lut-cool': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`hue-rotate(${10 * t}deg)`); filters.push(`saturate(${1 - 0.1 * t})`); filters.push(`brightness(${1 + 0.02 * t})`) }
break
}
case 'lut-warm': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`sepia(${0.2 * t})`); filters.push(`hue-rotate(${-5 * t}deg)`); filters.push(`saturate(${1 + 0.1 * t})`) }
break
}
case 'lut-muted': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`saturate(${1 - 0.5 * t})`); filters.push(`contrast(${1 - 0.05 * t})`) }
break
}
case 'lut-vivid': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`saturate(${1 + 0.6 * t})`); filters.push(`contrast(${1 + 0.1 * t})`) }
break
}
default:
break
}
return filters.join(' ')
}
/** Build CSS mask-image string from an EffectMask */
export function buildMaskImageValue(mask: EffectMask): string {
const { shape, x, y, width, height, feather, invert, rotation } = mask
// Ellipse without rotation: use CSS radial-gradient (simpler, more reliable)
if (shape === 'ellipse' && rotation === 0) {
const innerStop = Math.max(0, 100 - feather)
if (invert) {
return `radial-gradient(ellipse ${width}% ${height}% at ${x}% ${y}%, transparent ${innerStop}%, black 100%)`
}
return `radial-gradient(ellipse ${width}% ${height}% at ${x}% ${y}%, black ${innerStop}%, transparent 100%)`
}
// Rectangle or rotated ellipse: use inline SVG for precise shape + feather + rotation
const svgW = 200
const svgH = 200
const cx = (x / 100) * svgW
const cy = (y / 100) * svgH
const w = (width / 100) * svgW
const h = (height / 100) * svgH
const featherDev = (feather / 100) * Math.max(w, h) * 0.5
const bg = invert ? 'white' : 'black'
const fg = invert ? 'black' : 'white'
const rotAttr = rotation !== 0 ? ` transform="rotate(${rotation} ${cx} ${cy})"` : ''
const filterDef = featherDev > 0.5 ? `<filter id="mf"><feGaussianBlur stdDeviation="${featherDev.toFixed(1)}"/></filter>` : ''
const filterAttr = featherDev > 0.5 ? ' filter="url(#mf)"' : ''
let shapeEl: string
if (shape === 'ellipse') {
shapeEl = `<ellipse cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="${(w / 2).toFixed(1)}" ry="${(h / 2).toFixed(1)}" fill="${fg}"${rotAttr}/>`
} else {
shapeEl = `<rect x="${(cx - w / 2).toFixed(1)}" y="${(cy - h / 2).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="${fg}"${rotAttr}/>`
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}"><defs>${filterDef}</defs><rect width="${svgW}" height="${svgH}" fill="${bg}"/><g${filterAttr}>${shapeEl}</g></svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
export interface MaskedEffectOverlay {
effectId: string
filterCSS: string
maskImageValue: string
mask: EffectMask
}
/** Get overlay data for each masked effect on a clip */
export function getMaskedEffectOverlays(clip: TimelineClip): MaskedEffectOverlay[] {
if (!clip.effects) return []
const overlays: MaskedEffectOverlay[] = []
for (const fx of clip.effects) {
if (!fx.enabled || !fx.mask?.enabled) continue
const filterStr = getSingleEffectFilter(fx)
if (!filterStr) continue
overlays.push({
effectId: fx.id,
filterCSS: filterStr,
maskImageValue: buildMaskImageValue(fx.mask),
mask: fx.mask,
})
}
return overlays
}
export function getWipeClipPath(type: TransitionType, progress: number, isIn: boolean): string {
const p = Math.max(0, Math.min(1, progress)) * 100
switch (type) {
case 'wipe-left':
return isIn ? `inset(0 ${100 - p}% 0 0)` : `inset(0 0 0 ${100 - p}%)`
case 'wipe-right':
return isIn ? `inset(0 0 0 ${100 - p}%)` : `inset(0 ${100 - p}% 0 0)`
case 'wipe-up':
return isIn ? `inset(0 0 ${100 - p}% 0)` : `inset(${100 - p}% 0 0 0)`
case 'wipe-down':
return isIn ? `inset(${100 - p}% 0 0 0)` : `inset(0 0 ${100 - p}% 0)`
default:
return ''
}
}
/** Get the background color for transition overlay */
export function getTransitionBgColor(type: TransitionType): string | null {
if (type === 'fade-to-black') return 'black'
if (type === 'fade-to-white') return 'white'
return null
}
/** Format seconds into HH:MM:SS:FF timecode (24fps) */
export function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const frames = Math.floor((seconds % 1) * 24)
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}:${frames.toString().padStart(2, '0')}`
}
/** Parse HH:MM:SS:FF timecode string back to seconds (24fps) */
export function parseTime(tc: string): number | null {
const cleaned = tc.replace(/[^0-9:;]/g, '').replace(/;/g, ':')
const parts = cleaned.split(':')
if (parts.length === 4) {
const hrs = parseInt(parts[0], 10)
const mins = parseInt(parts[1], 10)
const secs = parseInt(parts[2], 10)
const frames = parseInt(parts[3], 10)
if (isNaN(hrs) || isNaN(mins) || isNaN(secs) || isNaN(frames)) return null
return hrs * 3600 + mins * 60 + secs + frames / 24
}
if (parts.length === 3) {
const mins = parseInt(parts[0], 10)
const secs = parseInt(parts[1], 10)
const frames = parseInt(parts[2], 10)
if (isNaN(mins) || isNaN(secs) || isNaN(frames)) return null
return mins * 60 + secs + frames / 24
}
if (parts.length === 2) {
const mins = parseInt(parts[0], 10)
const secs = parseInt(parts[1], 10)
if (isNaN(mins) || isNaN(secs)) return null
return mins * 60 + secs
}
if (parts.length === 1) {
const secs = parseInt(parts[0], 10)
if (isNaN(secs)) return null
return secs
}
return null
}
@@ -0,0 +1,50 @@
import { Heart, Image, Video } from 'lucide-react'
import type { GenSpaceTypeFilter } from '../../lib/genspace-gallery'
export function GenSpaceFilterEmptyState({
typeFilter,
showFavorites,
hasTypeMatches,
}: {
typeFilter: GenSpaceTypeFilter
showFavorites: boolean
hasTypeMatches: boolean
}) {
if (typeFilter !== 'all' && !hasTypeMatches) {
if (typeFilter === 'video') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
<Video className="h-12 w-12 text-zinc-700 mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No videos yet</h3>
<p className="text-zinc-500 text-sm">
Generate a video or switch the filter to see other media.
</p>
</div>
)
}
return (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
<Image className="h-12 w-12 text-zinc-700 mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No images yet</h3>
<p className="text-zinc-500 text-sm">
Generate an image or switch the filter to see other media.
</p>
</div>
)
}
if (showFavorites) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
<Heart className="h-12 w-12 text-zinc-700 mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No favorites yet</h3>
<p className="text-zinc-500 text-sm">
Click the heart icon on any asset to add it to your favorites.
</p>
</div>
)
}
return null
}
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react'
export type GallerySize = 'small' | 'medium' | 'large'
export const gallerySizeClasses: Record<GallerySize, string> = {
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7',
medium: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3',
}
function GridSmallIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="4" height="4" rx="0.5" />
<rect x="8" y="2" width="4" height="4" rx="0.5" />
<rect x="14" y="2" width="4" height="4" rx="0.5" />
<rect x="20" y="2" width="2" height="4" rx="0.5" />
<rect x="2" y="8" width="4" height="4" rx="0.5" />
<rect x="8" y="8" width="4" height="4" rx="0.5" />
<rect x="14" y="8" width="4" height="4" rx="0.5" />
<rect x="20" y="8" width="2" height="4" rx="0.5" />
<rect x="2" y="14" width="4" height="4" rx="0.5" />
<rect x="8" y="14" width="4" height="4" rx="0.5" />
<rect x="14" y="14" width="4" height="4" rx="0.5" />
<rect x="20" y="14" width="2" height="4" rx="0.5" />
</svg>
)
}
function GridMediumIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="6" height="6" rx="1" />
<rect x="10" y="2" width="6" height="6" rx="1" />
<rect x="18" y="2" width="4" height="6" rx="1" />
<rect x="2" y="10" width="6" height="6" rx="1" />
<rect x="10" y="10" width="6" height="6" rx="1" />
<rect x="18" y="10" width="4" height="6" rx="1" />
<rect x="2" y="18" width="6" height="4" rx="1" />
<rect x="10" y="18" width="6" height="4" rx="1" />
<rect x="18" y="18" width="4" height="4" rx="1" />
</svg>
)
}
function GridLargeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="1.5" />
<rect x="13" y="2" width="9" height="9" rx="1.5" />
<rect x="2" y="13" width="9" height="9" rx="1.5" />
<rect x="13" y="13" width="9" height="9" rx="1.5" />
</svg>
)
}
export function GenSpaceGallerySizeMenu({
gallerySize,
onGallerySizeChange,
}: {
gallerySize: GallerySize
onGallerySizeChange: (size: GallerySize) => void
}) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const CurrentIcon = gallerySize === 'small'
? GridSmallIcon
: gallerySize === 'medium'
? GridMediumIcon
: GridLargeIcon
return (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setOpen(value => !value)}
aria-label={`Gallery size: ${gallerySize}`}
aria-haspopup="menu"
aria-expanded={open}
className={`p-2 rounded-md transition-colors ${
open ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'
}`}
>
<CurrentIcon className="h-4 w-4" />
</button>
{open && (
<div className="absolute top-full mt-2 right-0 bg-zinc-800 border border-zinc-700 rounded-md p-2 min-w-[160px] shadow-xl z-50">
{([
{ value: 'small' as const, label: 'Small', icon: GridSmallIcon },
{ value: 'medium' as const, label: 'Medium', icon: GridMediumIcon },
{ value: 'large' as const, label: 'Large', icon: GridLargeIcon },
]).map(option => (
<button
key={option.value}
type="button"
onClick={() => { onGallerySizeChange(option.value); setOpen(false) }}
className={`w-full flex items-center justify-between px-2 py-2.5 rounded-md transition-colors text-left ${gallerySize === option.value ? 'bg-white/20 hover:bg-white/25' : 'hover:bg-zinc-700'}`}
>
<div className="flex items-center gap-3">
<option.icon className={`h-4 w-4 ${gallerySize === option.value ? 'text-white' : 'text-zinc-500'}`} />
<span className={`text-sm ${gallerySize === option.value ? 'text-white font-medium' : 'text-zinc-400'}`}>
{option.label}
</span>
</div>
{gallerySize === option.value && (
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,83 @@
import { Heart, Sparkles } from 'lucide-react'
import type { GenSpaceSortDir, GenSpaceSortKey, GenSpaceTypeFilter } from '../../lib/genspace-gallery'
import { GenSpaceGallerySizeMenu, type GallerySize } from './GenSpaceGallerySizeMenu'
import { GenSpaceSortMenu } from './GenSpaceSortMenu'
import { GenSpaceTypeFilter as TypeFilter } from './GenSpaceTypeFilter'
export function GenSpaceGalleryToolbar({
showBrowseLoras,
onBrowseLoras,
typeFilter,
onTypeFilterChange,
sortKey,
sortDir,
onSortKeyChange,
onToggleSortDir,
showFavorites,
favoriteCount,
onToggleFavorites,
gallerySize,
onGallerySizeChange,
}: {
showBrowseLoras: boolean
onBrowseLoras: () => void
typeFilter: GenSpaceTypeFilter
onTypeFilterChange: (value: GenSpaceTypeFilter) => void
sortKey: GenSpaceSortKey
sortDir: GenSpaceSortDir
onSortKeyChange: (key: GenSpaceSortKey) => void
onToggleSortDir: () => void
showFavorites: boolean
favoriteCount: number
onToggleFavorites: () => void
gallerySize: GallerySize
onGallerySizeChange: (size: GallerySize) => void
}) {
return (
<div className="flex items-center justify-between pb-2 gap-2">
<div className="flex items-center gap-2">
{showBrowseLoras && (
<button
type="button"
onClick={onBrowseLoras}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
<Sparkles className="h-4 w-4" /> Browse LoRAs
</button>
)}
</div>
<div className="flex items-center gap-2">
<TypeFilter value={typeFilter} onChange={onTypeFilterChange} />
<GenSpaceSortMenu
sortKey={sortKey}
sortDir={sortDir}
onSortKeyChange={onSortKeyChange}
onToggleSortDir={onToggleSortDir}
/>
<button
type="button"
onClick={onToggleFavorites}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
showFavorites
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800'
}`}
>
<Heart className={`h-4 w-4 ${showFavorites ? 'fill-current' : ''}`} />
Favorites
{favoriteCount > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
showFavorites ? 'bg-red-500/30 text-red-300' : 'bg-zinc-800 text-zinc-500'
}`}>
{favoriteCount}
</span>
)}
</button>
<GenSpaceGallerySizeMenu
gallerySize={gallerySize}
onGallerySizeChange={onGallerySizeChange}
/>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { SettingsDropdown } from '../../components/SettingsDropdown'
import {
GENSPACE_SORT_OPTIONS,
type GenSpaceSortDir,
type GenSpaceSortKey,
} from '../../lib/genspace-gallery'
const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white rounded-r-none'
export function GenSpaceSortMenu({
sortKey,
sortDir,
onSortKeyChange,
onToggleSortDir,
}: {
sortKey: GenSpaceSortKey
sortDir: GenSpaceSortDir
onSortKeyChange: (key: GenSpaceSortKey) => void
onToggleSortDir: () => void
}) {
const currentLabel = GENSPACE_SORT_OPTIONS.find(option => option.value === sortKey)?.label ?? 'Date'
const DirectionIcon = sortDir === 'desc' ? ChevronDown : ChevronUp
const directionLabel = sortDir === 'desc' ? 'Descending' : 'Ascending'
return (
<div className="flex items-center">
<SettingsDropdown
placement="below"
title="Sort"
value={sortKey}
onChange={next => onSortKeyChange(next as GenSpaceSortKey)}
triggerClassName={toolbarTriggerClass}
trigger={
<>
<span>{currentLabel}</span>
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
</>
}
options={GENSPACE_SORT_OPTIONS}
/>
<button
type="button"
onClick={onToggleSortDir}
title={directionLabel}
aria-label={`Sort ${directionLabel.toLowerCase()}`}
className="p-1.5 rounded-md rounded-l-none border-l border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
<DirectionIcon className="h-3.5 w-3.5" />
</button>
</div>
)
}
@@ -0,0 +1,35 @@
import { ChevronDown } from 'lucide-react'
import { SettingsDropdown } from '../../components/SettingsDropdown'
import {
GENSPACE_TYPE_FILTER_OPTIONS,
type GenSpaceTypeFilter,
} from '../../lib/genspace-gallery'
const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white'
export function GenSpaceTypeFilter({
value,
onChange,
}: {
value: GenSpaceTypeFilter
onChange: (value: GenSpaceTypeFilter) => void
}) {
const currentLabel = GENSPACE_TYPE_FILTER_OPTIONS.find(option => option.value === value)?.label ?? 'All'
return (
<SettingsDropdown
placement="below"
title="Type"
value={value}
onChange={next => onChange(next as GenSpaceTypeFilter)}
triggerClassName={toolbarTriggerClass}
trigger={
<>
<span>{currentLabel}</span>
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
</>
}
options={GENSPACE_TYPE_FILTER_OPTIONS}
/>
)
}
@@ -0,0 +1,67 @@
import { useCallback, useMemo, useState } from 'react'
import type { Asset } from '../../types/project-model'
import {
defaultSortDir,
filterGenSpaceAssets,
sortGenSpaceAssets,
type GenSpaceSortDir,
type GenSpaceSortKey,
type GenSpaceTypeFilter,
} from '../../lib/genspace-gallery'
export function useGenSpaceGallery(assets: Asset[]) {
const [typeFilter, setTypeFilter] = useState<GenSpaceTypeFilter>('all')
const [sortKey, setSortKey] = useState<GenSpaceSortKey>('createdAt')
const [sortDir, setSortDir] = useState<GenSpaceSortDir>(() => defaultSortDir('createdAt'))
const [showFavorites, setShowFavorites] = useState(false)
const filteredAssets = useMemo(
() => sortGenSpaceAssets(
filterGenSpaceAssets(assets, typeFilter, showFavorites),
sortKey,
sortDir,
),
[assets, typeFilter, showFavorites, sortKey, sortDir],
)
const favoriteCount = useMemo(
() => filterGenSpaceAssets(assets, 'all', true).length,
[assets],
)
const hasTypeMatches = useMemo(
() => filterGenSpaceAssets(assets, typeFilter, false).length > 0,
[assets, typeFilter],
)
const onSortKeyChange = useCallback((key: GenSpaceSortKey) => {
if (key === sortKey) {
setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc'))
return
}
setSortKey(key)
setSortDir(defaultSortDir(key))
}, [sortKey])
const onToggleSortDir = useCallback(() => {
setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc'))
}, [])
const onToggleFavorites = useCallback(() => {
setShowFavorites(value => !value)
}, [])
return {
typeFilter,
setTypeFilter,
sortKey,
sortDir,
onSortKeyChange,
onToggleSortDir,
showFavorites,
onToggleFavorites,
filteredAssets,
favoriteCount,
hasTypeMatches,
}
}
@@ -0,0 +1,40 @@
import { useCallback, useRef, useState } from 'react'
import type { PanelImperativeHandle } from 'react-resizable-panels'
import {
DEFAULT_PROMPT_BAR_HEIGHT,
PROMPT_BAR_HEIGHT_LIMITS,
clampPromptBarHeight,
loadPromptBarHeight,
savePromptBarHeight,
} from '../../lib/genspace-layout'
export function useGenSpacePromptBarHeight() {
const initialHeightRef = useRef(loadPromptBarHeight())
const heightRef = useRef(initialHeightRef.current)
const panelRef = useRef<PanelImperativeHandle | null>(null)
const [promptBarHeight, setPromptBarHeight] = useState(initialHeightRef.current)
const persistHeight = useCallback((inPixels: number) => {
const next = clampPromptBarHeight(inPixels)
if (next === heightRef.current) return
heightRef.current = next
setPromptBarHeight(next)
savePromptBarHeight(next)
}, [])
const resetHeight = useCallback(() => {
heightRef.current = DEFAULT_PROMPT_BAR_HEIGHT
setPromptBarHeight(DEFAULT_PROMPT_BAR_HEIGHT)
savePromptBarHeight(DEFAULT_PROMPT_BAR_HEIGHT)
panelRef.current?.resize(DEFAULT_PROMPT_BAR_HEIGHT)
}, [])
return {
initialHeight: initialHeightRef.current,
promptBarHeight,
panelRef,
persistHeight,
resetHeight,
limits: PROMPT_BAR_HEIGHT_LIMITS,
}
}