Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 & reset overrides
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user