Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { KeyRound, X, Zap } from 'lucide-react'
|
||||
import { ApiKeyHelperRow, LtxApiKeyInput } from './LtxApiKeyInput'
|
||||
|
||||
export type ApiKeyType = 'ltx' | 'fal'
|
||||
|
||||
export interface ApiGatewaySection {
|
||||
keyType: ApiKeyType
|
||||
title: string
|
||||
description: string
|
||||
required: boolean
|
||||
isConfigured: boolean
|
||||
inputLabel: string
|
||||
placeholder?: string
|
||||
onSave: (apiKey: string) => Promise<void> | void
|
||||
onGetKey?: () => void
|
||||
getKeyLabel?: string
|
||||
}
|
||||
|
||||
export interface ApiGatewayModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
description: string
|
||||
sections: ApiGatewaySection[]
|
||||
blocking?: boolean
|
||||
}
|
||||
|
||||
const KEY_TYPE_META: Record<ApiKeyType, { icon: typeof Zap; iconClass: string; chipClass: string }> = {
|
||||
ltx: {
|
||||
icon: Zap,
|
||||
iconClass: 'text-blue-400',
|
||||
chipClass: 'bg-amber-500/10 text-amber-300',
|
||||
},
|
||||
fal: {
|
||||
icon: KeyRound,
|
||||
iconClass: 'text-cyan-400',
|
||||
chipClass: 'bg-zinc-800 text-zinc-400',
|
||||
},
|
||||
}
|
||||
|
||||
export function ApiGatewayModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
sections,
|
||||
blocking = false,
|
||||
}: ApiGatewayModalProps) {
|
||||
const [values, setValues] = useState<Record<ApiKeyType, string>>({ ltx: '', fal: '' })
|
||||
const [isSaving, setIsSaving] = useState<Record<ApiKeyType, boolean>>({ ltx: false, fal: false })
|
||||
const [errors, setErrors] = useState<Record<ApiKeyType, string | null>>({ ltx: null, fal: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setValues({ ltx: '', fal: '' })
|
||||
setIsSaving({ ltx: false, fal: false })
|
||||
setErrors({ ltx: null, fal: null })
|
||||
}, [isOpen])
|
||||
|
||||
const allRequiredConfigured = useMemo(() => {
|
||||
const requiredSections = sections.filter((section) => section.required)
|
||||
if (requiredSections.length === 0) return true
|
||||
return requiredSections.every((section) => section.isConfigured)
|
||||
}, [sections])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!allRequiredConfigured) return
|
||||
if (sections.length <= 1) {
|
||||
onClose()
|
||||
}
|
||||
}, [isOpen, onClose, sections.length, allRequiredConfigured])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !blocking) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [blocking, isOpen, onClose])
|
||||
|
||||
const handleSave = async (section: ApiGatewaySection) => {
|
||||
const keyType = section.keyType
|
||||
const trimmedKey = (values[keyType] ?? '').trim()
|
||||
if (!trimmedKey) {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: `Please enter a valid ${section.inputLabel}.` }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving((prev) => ({ ...prev, [keyType]: true }))
|
||||
setErrors((prev) => ({ ...prev, [keyType]: null }))
|
||||
try {
|
||||
await section.onSave(trimmedKey)
|
||||
setValues((prev) => ({ ...prev, [keyType]: '' }))
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.trim()) {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: err.message }))
|
||||
} else {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: 'Failed to save API key.' }))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving((prev) => ({ ...prev, [keyType]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const requiredMissing = useMemo(() => sections.some((section) => section.required && !section.isConfigured), [sections])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-[620px] rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">{title}</h2>
|
||||
</div>
|
||||
{!blocking && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
aria-label="Close API gateway modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<p className="text-sm leading-relaxed text-zinc-300">{description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => {
|
||||
const meta = KEY_TYPE_META[section.keyType]
|
||||
const Icon = meta.icon
|
||||
const configured = section.isConfigured
|
||||
const saving = isSaving[section.keyType]
|
||||
const value = values[section.keyType] ?? ''
|
||||
const error = errors[section.keyType]
|
||||
const canSubmit = value.trim().length > 0 && !saving
|
||||
|
||||
return (
|
||||
<div key={section.keyType} className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${meta.iconClass}`} />
|
||||
<h3 className="text-sm font-semibold text-white">{section.title}</h3>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${section.required ? meta.chipClass : 'bg-zinc-800 text-zinc-500'}`}>
|
||||
{section.required ? 'Required' : 'Optional'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">{section.description}</p>
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-1 rounded inline-flex items-center gap-1.5 ${configured ? 'bg-green-500/10 text-green-400' : 'bg-zinc-800 text-zinc-500'}`}>
|
||||
{configured ? 'Configured' : 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!configured && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-zinc-300">{section.inputLabel}</label>
|
||||
<div className="flex gap-2">
|
||||
<LtxApiKeyInput
|
||||
value={value}
|
||||
onChange={(event) => setValues((prev) => ({ ...prev, [section.keyType]: event.target.value }))}
|
||||
placeholder={section.placeholder ?? 'Paste your API key'}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSave(section)}
|
||||
disabled={!canSubmit}
|
||||
className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Key'}
|
||||
</button>
|
||||
</div>
|
||||
<ApiKeyHelperRow
|
||||
label={section.getKeyLabel ?? 'Get API key'}
|
||||
onOpenKey={section.onGetKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{blocking && requiredMissing && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
Required API keys are missing. Add them to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 1 && allRequiredConfigured && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronLeft, ChevronRight, Copy, X } from 'lucide-react'
|
||||
import type { Asset } from '../types/project-model'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { formatPipelineDisplayName } from '../lib/video-generation-model-specs'
|
||||
|
||||
const mediaClassName =
|
||||
'mx-auto max-h-[calc(100dvh-18vh-7rem)] max-w-full rounded-xl object-contain'
|
||||
|
||||
// Duration can be a raw float (e.g. extend output: 12.041667s). Show a clean value:
|
||||
// integers as-is, otherwise at most 2 decimals with trailing zeros trimmed.
|
||||
function formatSeconds(seconds: number): string {
|
||||
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function formatDurationLabel(asset: Asset): string | null {
|
||||
if (asset.type === 'image') return 'Image'
|
||||
// Requested Auto is persisted as generationParams.duration === null. Do not fall through
|
||||
// to omitting the chip — that made Auto clips look like they had no duration at all.
|
||||
if (asset.generationParams?.duration === null) return 'Auto duration'
|
||||
if (asset.duration) return `${formatSeconds(asset.duration)}s`
|
||||
return null
|
||||
}
|
||||
|
||||
export interface AssetPreviewModalProps {
|
||||
asset: Asset
|
||||
/** 0-based index into the current filtered gallery. */
|
||||
index: number
|
||||
total: number
|
||||
canGoPrev: boolean
|
||||
canGoNext: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AssetPreviewModal({
|
||||
asset,
|
||||
index,
|
||||
total,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
onPrev,
|
||||
onNext,
|
||||
onClose,
|
||||
}: AssetPreviewModalProps) {
|
||||
const [copiedPrompt, setCopiedPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); onPrev() }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); onNext() }
|
||||
else if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onPrev, onNext, onClose])
|
||||
|
||||
// Reset copy affordance when paging between assets.
|
||||
useEffect(() => {
|
||||
setCopiedPrompt(false)
|
||||
}, [asset.id])
|
||||
|
||||
// Image assets also store a placeholder `model` (e.g. "fast" for Z-Image) that is not an
|
||||
// LTX video pipeline — only label known video pipelines on video assets. Prefer the label
|
||||
// captured at generation time: the local "fast" pipeline id is shared by every LTX version,
|
||||
// so mapping it here would report the wrong version.
|
||||
const modelLabel = asset.type === 'video'
|
||||
? asset.generationParams?.modelLabel ?? formatPipelineDisplayName(asset.generationParams?.model)
|
||||
: null
|
||||
const metaParts = [
|
||||
modelLabel,
|
||||
asset.resolution || null,
|
||||
formatDurationLabel(asset),
|
||||
].filter(Boolean)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
disabled={!canGoPrev}
|
||||
className={`absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
|
||||
canGoPrev
|
||||
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
|
||||
: 'bg-white/5 text-zinc-600 cursor-default'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
disabled={!canGoNext}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
|
||||
canGoNext
|
||||
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
|
||||
: 'bg-white/5 text-zinc-600 cursor-default'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="relative flex max-h-full w-full max-w-5xl flex-col px-20 py-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<span className="text-sm text-zinc-500 font-medium">
|
||||
{index + 1} / {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{asset.type === 'video' ? (
|
||||
<video
|
||||
key={asset.id}
|
||||
src={pathToFileUrl(asset.path)}
|
||||
controls
|
||||
autoPlay
|
||||
className={mediaClassName}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={asset.id}
|
||||
src={pathToFileUrl(asset.path)}
|
||||
alt=""
|
||||
className={mediaClassName}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-3 shrink-0 text-center">
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<p className="max-h-[18vh] overflow-y-auto whitespace-pre-wrap break-words text-left text-zinc-300">{asset.prompt}</p>
|
||||
{asset.prompt && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(asset.prompt)
|
||||
setCopiedPrompt(true)
|
||||
setTimeout(() => setCopiedPrompt(false), 2000)
|
||||
}}
|
||||
className="shrink-0 p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
title="Copy prompt"
|
||||
>
|
||||
{copiedPrompt ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{metaParts.join(' • ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { Music } from 'lucide-react'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
interface AudioClipInfo {
|
||||
url: string
|
||||
name: string
|
||||
startTime: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioClips: AudioClipInfo[]
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
// Global waveform cache: URL → Float32Array of peak amplitudes (one per pixel-bucket)
|
||||
export const waveformCache = new Map<string, Float32Array>()
|
||||
const pendingDecodes = new Set<string>()
|
||||
|
||||
// Convert a base64 string to an ArrayBuffer
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
// Decode audio file and extract amplitude envelope
|
||||
export async function computeWaveform(url: string, buckets: number = 800): Promise<Float32Array> {
|
||||
if (waveformCache.has(url)) return waveformCache.get(url)!
|
||||
|
||||
if (pendingDecodes.has(url)) {
|
||||
while (pendingDecodes.has(url)) {
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
}
|
||||
if (waveformCache.has(url)) return waveformCache.get(url)!
|
||||
}
|
||||
|
||||
pendingDecodes.add(url)
|
||||
try {
|
||||
let arrayBuffer: ArrayBuffer
|
||||
|
||||
if (url.startsWith('file://') && (window as any).electronAPI?.readLocalFile) {
|
||||
const { data } = await (window as any).electronAPI.readLocalFile({ filePath: url })
|
||||
arrayBuffer = base64ToArrayBuffer(data)
|
||||
} else {
|
||||
const response = await fetch(url)
|
||||
arrayBuffer = await response.arrayBuffer()
|
||||
}
|
||||
|
||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
|
||||
audioCtx.close()
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0)
|
||||
const samplesPerBucket = Math.floor(channelData.length / buckets)
|
||||
const peaks = new Float32Array(buckets)
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
let max = 0
|
||||
const start = i * samplesPerBucket
|
||||
const end = Math.min(start + samplesPerBucket, channelData.length)
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channelData[j])
|
||||
if (abs > max) max = abs
|
||||
}
|
||||
peaks[i] = max
|
||||
}
|
||||
|
||||
waveformCache.set(url, peaks)
|
||||
return peaks
|
||||
} finally {
|
||||
pendingDecodes.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
export function AudioWaveform({ audioClips, currentTime, isPlaying }: AudioWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [waveforms, setWaveforms] = useState<Map<string, Float32Array>>(new Map())
|
||||
const animRef = useRef<number>(0)
|
||||
|
||||
// Load waveform data for all clips
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadAll = async () => {
|
||||
const newMap = new Map<string, Float32Array>()
|
||||
for (const clip of audioClips) {
|
||||
if (!clip.url) continue
|
||||
try {
|
||||
const peaks = await computeWaveform(clip.url)
|
||||
if (cancelled) return
|
||||
newMap.set(clip.url, peaks)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to decode audio waveform: ${clip.url} ${e}`)
|
||||
}
|
||||
}
|
||||
if (!cancelled) setWaveforms(newMap)
|
||||
}
|
||||
loadAll()
|
||||
return () => { cancelled = true }
|
||||
}, [audioClips.map(c => c.url).join(',')])
|
||||
|
||||
// Draw waveform on canvas
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = container.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// Grid lines (subtle)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)'
|
||||
ctx.lineWidth = 1
|
||||
const centerY = h / 2
|
||||
// Horizontal center line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, centerY)
|
||||
ctx.lineTo(w, centerY)
|
||||
ctx.stroke()
|
||||
// Quarter lines
|
||||
for (const frac of [0.25, 0.75]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h * frac)
|
||||
ctx.lineTo(w, h * frac)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (audioClips.length === 0) return
|
||||
|
||||
// For simplicity, render the first (or longest) audio clip's waveform
|
||||
// filling the entire monitor width. If multiple clips, overlay them.
|
||||
const maxAmplitude = h * 0.4 // 40% of height above and below center
|
||||
|
||||
for (let ci = 0; ci < audioClips.length; ci++) {
|
||||
const clip = audioClips[ci]
|
||||
const peaks = waveforms.get(clip.url)
|
||||
if (!peaks || peaks.length === 0) continue
|
||||
|
||||
// Map clip's time range to screen
|
||||
const clipProgress = (currentTime - clip.startTime) / clip.duration
|
||||
|
||||
// Color: emerald with some alpha for overlapping
|
||||
const alpha = audioClips.length > 1 ? 0.6 : 0.9
|
||||
const gradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
||||
gradient.addColorStop(0, `rgba(52, 211, 153, ${alpha})`) // emerald-400
|
||||
gradient.addColorStop(0.5, `rgba(16, 185, 129, ${alpha})`) // emerald-500
|
||||
gradient.addColorStop(1, `rgba(52, 211, 153, ${alpha})`)
|
||||
|
||||
// Draw filled waveform (mirrored around center)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.beginPath()
|
||||
|
||||
// Top half (positive)
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmplitude
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
|
||||
// Bottom half (negative, traced backwards)
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmplitude
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Played region: brighter overlay
|
||||
if (clipProgress > 0 && clipProgress <= 1) {
|
||||
const playedX = clipProgress * w
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, playedX, h)
|
||||
ctx.clip()
|
||||
|
||||
const brightGradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
||||
brightGradient.addColorStop(0, 'rgba(52, 211, 153, 0.3)')
|
||||
brightGradient.addColorStop(0.5, 'rgba(16, 185, 129, 0.3)')
|
||||
brightGradient.addColorStop(1, 'rgba(52, 211, 153, 0.3)')
|
||||
|
||||
ctx.fillStyle = brightGradient
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmplitude
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmplitude
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Playhead line
|
||||
if (clipProgress >= 0 && clipProgress <= 1) {
|
||||
const px = clipProgress * w
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 4)
|
||||
ctx.lineTo(px, h - 4)
|
||||
ctx.stroke()
|
||||
|
||||
// Small triangle at top
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 2)
|
||||
ctx.lineTo(px - 4, 8)
|
||||
ctx.lineTo(px + 4, 8)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// If no waveform data loaded yet, show loading indicator
|
||||
if (waveforms.size === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)'
|
||||
ctx.font = '12px system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Loading waveform...', w / 2, centerY)
|
||||
}
|
||||
}, [audioClips, currentTime, waveforms])
|
||||
|
||||
// Animate during playback
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
const animate = () => {
|
||||
draw()
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(animRef.current)
|
||||
} else {
|
||||
draw()
|
||||
}
|
||||
}, [isPlaying, draw])
|
||||
|
||||
// Redraw on resize
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (containerRef.current) observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [draw])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full flex flex-col">
|
||||
{/* Canvas fills available space */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
{/* Small music icon badge */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2 py-1 rounded bg-black/60">
|
||||
<Music className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-[10px] text-emerald-400 font-medium">Audio</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Clip names */}
|
||||
{audioClips.length > 0 && (
|
||||
<div className="flex-shrink-0 px-3 py-1.5 bg-zinc-950 border-t border-zinc-800">
|
||||
{audioClips.map((clip, i) => (
|
||||
<p key={i} className="text-[10px] text-zinc-500 truncate">
|
||||
{clip.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Compact inline waveform for timeline audio clips ---
|
||||
|
||||
interface ClipWaveformProps {
|
||||
url: string
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ClipWaveform({ url, className = '', color = 'rgba(52, 211, 153, 0.7)' }: ClipWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return
|
||||
let cancelled = false
|
||||
computeWaveform(url, 200).then(p => {
|
||||
if (!cancelled) setPeaks(p)
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [url])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !peaks) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = container.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w === 0 || h === 0) return
|
||||
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const centerY = h / 2
|
||||
const maxAmp = h * 0.45
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmp
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmp
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}, [peaks, color])
|
||||
|
||||
useEffect(() => {
|
||||
draw()
|
||||
}, [draw])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (containerRef.current) observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [draw])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`absolute inset-0 ${className}`}>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Copy, Check } from 'lucide-react'
|
||||
import { useDevFlags, DEV_FLAGS, type DevFlagKey } from '../contexts/DevFlagsContext'
|
||||
|
||||
// Master-detail feature-flag panel (Ctrl/Cmd+Shift+D). List on the left
|
||||
// (green = enabled, ring = selected), detail + toggle on the right.
|
||||
export function DevPanel() {
|
||||
const { flags, setFlag, isPanelOpen, setPanelOpen } = useDevFlags()
|
||||
const [selectedKey, setSelectedKey] = useState<DevFlagKey>(DEV_FLAGS[0]?.key)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelOpen) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPanelOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isPanelOpen, setPanelOpen])
|
||||
|
||||
if (!isPanelOpen) return null
|
||||
|
||||
const selected = DEV_FLAGS.find(f => f.key === selectedKey) ?? DEV_FLAGS[0]
|
||||
const isOn = selected ? flags[selected.key] : false
|
||||
|
||||
const copyKey = () => {
|
||||
if (!selected) return
|
||||
void navigator.clipboard?.writeText(selected.key).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex h-[440px] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Dev Panel</h2>
|
||||
<p className="text-[11px] text-zinc-500">Local-only feature toggles, saved on this machine.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPanelOpen(false)}
|
||||
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: list + detail */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* List */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-zinc-800 p-2">
|
||||
{DEV_FLAGS.map(spec => {
|
||||
const on = flags[spec.key]
|
||||
const isSelected = spec.key === selectedKey
|
||||
return (
|
||||
<button
|
||||
key={spec.key}
|
||||
onClick={() => setSelectedKey(spec.key)}
|
||||
className={`mb-1 w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'text-white ring-1 ring-blue-500/60 ' + (on ? 'bg-emerald-900/40' : 'bg-zinc-800')
|
||||
: on
|
||||
? 'bg-emerald-900/30 text-emerald-100 hover:bg-emerald-900/50'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{spec.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-5">
|
||||
{selected && (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{selected.label}</h3>
|
||||
<button
|
||||
onClick={copyKey}
|
||||
className="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
title="Copy flag key"
|
||||
>
|
||||
{selected.key}
|
||||
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-zinc-400">Enabled</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
onClick={() => setFlag(selected.key, !isOn)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
isOn ? 'bg-blue-500' : 'bg-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="mb-1 text-xs font-medium text-zinc-400">Description</p>
|
||||
<p className="text-sm leading-relaxed text-zinc-300">{selected.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||
import { X, Download, FolderOpen, Film, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { DEFAULT_SUBTITLE_STYLE } from '../types/project-model'
|
||||
import type { Track, TimelineClip } from '../types/project-model'
|
||||
import {
|
||||
selectActiveTimeline,
|
||||
selectAssets,
|
||||
selectClipPathFromAssets,
|
||||
selectClips,
|
||||
selectShowExportModal,
|
||||
selectSubtitles,
|
||||
selectTracks,
|
||||
} from '../views/editor/editor-selectors'
|
||||
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
|
||||
|
||||
interface ExportModalProps {
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type ExportStatus = 'idle' | 'exporting' | 'done' | 'error'
|
||||
type ExportCodec = 'h264' | 'prores' | 'vp9'
|
||||
|
||||
interface ExportSettings {
|
||||
codec: ExportCodec
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9
|
||||
}
|
||||
|
||||
const CODEC_INFO: Record<ExportCodec, { label: string; ext: string; description: string; filterName: string }> = {
|
||||
h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' },
|
||||
prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' },
|
||||
vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' },
|
||||
}
|
||||
|
||||
const RESOLUTIONS = [
|
||||
{ label: '4K (3840 x 2160)', width: 3840, height: 2160 },
|
||||
{ label: '1080p (1920 x 1080)', width: 1920, height: 1080 },
|
||||
{ label: '720p (1280 x 720)', width: 1280, height: 720 },
|
||||
]
|
||||
|
||||
const FRAME_RATES = [24, 25, 30, 60]
|
||||
|
||||
const PRORES_PROFILES = [
|
||||
{ value: 0, label: 'Proxy' },
|
||||
{ value: 1, label: 'LT' },
|
||||
{ value: 2, label: 'Standard' },
|
||||
{ value: 3, label: 'HQ' },
|
||||
]
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// Generate FCPXML for Premiere / DaVinci
|
||||
function generateFCPXML(
|
||||
clips: TimelineClip[],
|
||||
tracks: Track[],
|
||||
projectName: string,
|
||||
timelineName: string,
|
||||
fps: number = 24
|
||||
): string {
|
||||
const frameDuration = `${Math.round(100 * fps)}/${100 * fps}s`
|
||||
const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0)
|
||||
const totalFrames = Math.ceil(totalDuration * fps)
|
||||
|
||||
const assetEntries: string[] = []
|
||||
const seenAssets = new Set<string>()
|
||||
for (const clip of clips) {
|
||||
const assetId = clip.assetId || clip.id
|
||||
if (seenAssets.has(assetId)) continue
|
||||
seenAssets.add(assetId)
|
||||
|
||||
const assetPath = clip.asset?.path || ''
|
||||
const dur = clip.asset?.duration || clip.duration
|
||||
const durFrames = Math.ceil(dur * fps)
|
||||
const format = clip.type === 'audio' ? 'audio' : 'video'
|
||||
|
||||
assetEntries.push(
|
||||
` <asset id="${escapeXml(assetId)}" name="${escapeXml(clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip')}" src="${escapeXml(assetPath)}" start="0s" duration="${durFrames}/${fps}s" hasVideo="${format === 'video' ? '1' : '0'}" hasAudio="1" format="r1" />`
|
||||
)
|
||||
}
|
||||
|
||||
const trackGroups: Map<number, TimelineClip[]> = new Map()
|
||||
for (const clip of clips) {
|
||||
if (!trackGroups.has(clip.trackIndex)) trackGroups.set(clip.trackIndex, [])
|
||||
trackGroups.get(clip.trackIndex)!.push(clip)
|
||||
}
|
||||
|
||||
const laneXml: string[] = []
|
||||
const sortedTrackIndices = [...trackGroups.keys()].sort((a, b) => a - b)
|
||||
|
||||
for (const trackIdx of sortedTrackIndices) {
|
||||
const trackClips = trackGroups.get(trackIdx)!.sort((a, b) => a.startTime - b.startTime)
|
||||
const clipElements: string[] = []
|
||||
|
||||
for (const clip of trackClips) {
|
||||
const assetId = clip.assetId || clip.id
|
||||
const startFrame = Math.round(clip.startTime * fps)
|
||||
const durFrames = Math.round(clip.duration * fps)
|
||||
const trimStartFrame = Math.round(clip.trimStart * fps)
|
||||
const name = clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip'
|
||||
|
||||
let clipXml = ` <asset-clip ref="${escapeXml(assetId)}" name="${escapeXml(name)}" offset="${startFrame}/${fps}s" duration="${durFrames}/${fps}s" start="${trimStartFrame}/${fps}s"`
|
||||
if (clip.speed !== 1) {
|
||||
clipXml += ` tcFormat="NDF"`
|
||||
}
|
||||
clipXml += ` />`
|
||||
clipElements.push(clipXml)
|
||||
}
|
||||
|
||||
const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}`
|
||||
laneXml.push(
|
||||
` <!-- ${escapeXml(trackName)} -->\n` +
|
||||
clipElements.join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.10">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat${fps === 24 ? '1080p2398' : '1080p' + fps}" frameDuration="${frameDuration}" width="1920" height="1080" />
|
||||
${assetEntries.join('\n')}
|
||||
</resources>
|
||||
<library>
|
||||
<event name="${escapeXml(projectName)}">
|
||||
<project name="${escapeXml(timelineName)}">
|
||||
<sequence format="r1" duration="${totalFrames}/${fps}s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
${laneXml.join('\n')}
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>`
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function ExportModal({ projectName }: ExportModalProps) {
|
||||
const { closeExportModal } = useEditorActions()
|
||||
const isOpen = useEditorStore(selectShowExportModal)
|
||||
const timeline = useEditorStore(selectActiveTimeline)
|
||||
const assets = useEditorStore(selectAssets)
|
||||
const clips = useEditorStore(selectClips)
|
||||
const tracks = useEditorStore(selectTracks)
|
||||
const subtitles = useEditorStore(selectSubtitles)
|
||||
|
||||
const exportClips = useMemo(() => (
|
||||
clips
|
||||
.filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio')
|
||||
.filter(clip => tracks[clip.trackIndex]?.enabled !== false)
|
||||
.map(clip => ({
|
||||
path: selectClipPathFromAssets(assets, 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,
|
||||
}))
|
||||
), [assets, clips, tracks])
|
||||
|
||||
const subtitleData = useMemo(() => (
|
||||
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 || {}),
|
||||
},
|
||||
}
|
||||
})
|
||||
), [subtitles, tracks])
|
||||
|
||||
const letterbox = useMemo(() => {
|
||||
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 config = best.letterbox!
|
||||
return {
|
||||
ratio: config.aspectRatio === 'custom'
|
||||
? (config.customRatio || 2.35)
|
||||
: (LETTERBOX_RATIO_MAP[config.aspectRatio] || 2.35),
|
||||
color: config.color || '#000000',
|
||||
opacity: (config.opacity ?? 100) / 100,
|
||||
}
|
||||
}, [clips, tracks])
|
||||
|
||||
const [exportStatus, setExportStatus] = useState<ExportStatus>('idle')
|
||||
const [exportType, setExportType] = useState<'package' | 'video' | null>(null)
|
||||
const [exportProgress, setExportProgress] = useState(0)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [exportPath, setExportPath] = useState<string | null>(null)
|
||||
const [exportFrameInfo, setExportFrameInfo] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
|
||||
// Export settings
|
||||
const [settings, setSettings] = useState<ExportSettings>({
|
||||
codec: 'h264',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 24,
|
||||
quality: 18, // CRF 18 for h264
|
||||
})
|
||||
const [burnSubtitles, setBurnSubtitles] = useState(true)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
closeExportModal()
|
||||
}, [closeExportModal])
|
||||
|
||||
const hasSubtitles = subtitleData.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
setExportPath(null)
|
||||
setExportFrameInfo('')
|
||||
abortRef.current = false
|
||||
}, [isOpen])
|
||||
|
||||
// Update quality default when codec changes
|
||||
const handleCodecChange = useCallback((codec: ExportCodec) => {
|
||||
let quality = 18
|
||||
if (codec === 'prores') quality = 3 // HQ profile
|
||||
if (codec === 'vp9') quality = 8 // 8 Mbps
|
||||
setSettings(prev => ({ ...prev, codec, quality }))
|
||||
}, [])
|
||||
|
||||
const handleExportPackage = useCallback(async () => {
|
||||
if (!timeline) return
|
||||
setExportType('package')
|
||||
setExportStatus('exporting')
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
|
||||
try {
|
||||
const filePath = await window.electronAPI?.showSaveDialog({
|
||||
title: 'Export FCPXML Package',
|
||||
defaultPath: `${projectName}_${timeline.name}.fcpxml`,
|
||||
filters: [
|
||||
{ name: 'Final Cut Pro XML', extensions: ['fcpxml'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePath) {
|
||||
setExportStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
setExportProgress(50)
|
||||
const xml = generateFCPXML(clips, tracks, projectName, timeline.name)
|
||||
const result = await window.electronAPI?.saveFile({ filePath, data: xml })
|
||||
if (result?.success) {
|
||||
setExportProgress(100)
|
||||
setExportPath(filePath)
|
||||
setExportStatus('done')
|
||||
} else {
|
||||
throw new Error(result && !result.success ? result.error : 'Failed to save file')
|
||||
}
|
||||
} catch (err) {
|
||||
setExportError(String(err))
|
||||
setExportStatus('error')
|
||||
}
|
||||
}, [clips, tracks, timeline, projectName])
|
||||
|
||||
const handleExportVideo = useCallback(async () => {
|
||||
if (!timeline || clips.length === 0) return
|
||||
setExportType('video')
|
||||
setExportStatus('exporting')
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
setExportFrameInfo('Preparing...')
|
||||
abortRef.current = false
|
||||
|
||||
try {
|
||||
const codecInfo = CODEC_INFO[settings.codec]
|
||||
|
||||
const filePath = await window.electronAPI?.showSaveDialog({
|
||||
title: `Export ${codecInfo.label}`,
|
||||
defaultPath: `${projectName}_${timeline.name}.${codecInfo.ext}`,
|
||||
filters: [
|
||||
{ name: codecInfo.filterName, extensions: [codecInfo.ext] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePath) {
|
||||
setExportStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// Build clip data for ffmpeg native export (video/image + audio clips)
|
||||
setExportFrameInfo('Starting ffmpeg...')
|
||||
|
||||
const result = await window.electronAPI?.exportNative({
|
||||
clips: exportClips,
|
||||
outputPath: filePath,
|
||||
codec: settings.codec,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
fps: settings.fps,
|
||||
quality: settings.quality,
|
||||
letterbox: letterbox || undefined,
|
||||
subtitles: burnSubtitles && subtitleData.length > 0 ? subtitleData : undefined,
|
||||
})
|
||||
|
||||
if (result && !result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setExportProgress(100)
|
||||
setExportPath(filePath)
|
||||
setExportFrameInfo('Export complete')
|
||||
setExportStatus('done')
|
||||
} catch (err) {
|
||||
setExportError(String(err))
|
||||
setExportStatus('error')
|
||||
}
|
||||
}, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
abortRef.current = true
|
||||
window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {})
|
||||
setExportStatus('idle')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={closeModal}>
|
||||
<div
|
||||
className="bg-zinc-900 rounded-2xl border border-zinc-700/50 shadow-2xl w-full max-w-lg relative overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-bold text-white">Export</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{/* Exporting state */}
|
||||
{exportStatus === 'exporting' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
||||
<span className="text-sm text-zinc-300">
|
||||
{exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${exportProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{exportProgress}% complete</p>
|
||||
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
||||
</div>
|
||||
{exportType === 'video' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done state */}
|
||||
{exportStatus === 'done' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">Export complete</p>
|
||||
<p className="text-xs text-zinc-500 truncate max-w-[340px]">{exportPath}</p>
|
||||
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
if (exportPath) {
|
||||
window.electronAPI?.openParentFolderOfFile({ filePath: exportPath })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Show in Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
}}
|
||||
>
|
||||
Export Another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{exportStatus === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">Export failed</p>
|
||||
<p className="text-xs text-red-400 max-w-[340px] break-words">{exportError}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state — settings */}
|
||||
{exportStatus === 'idle' && (
|
||||
<div className="space-y-5">
|
||||
{/* Package export (compact) */}
|
||||
<button
|
||||
onClick={handleExportPackage}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl border border-zinc-700/50 bg-zinc-800/50 hover:bg-zinc-800 hover:border-zinc-600 transition-all group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-zinc-700/50 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-zinc-300" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-white">Package (FCPXML)</p>
|
||||
<p className="text-[10px] text-zinc-500">For Premiere Pro & DaVinci Resolve</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="DaVinci Resolve">
|
||||
<span className="text-[8px] font-bold text-orange-400">DR</span>
|
||||
</div>
|
||||
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="Premiere Pro">
|
||||
<span className="text-[8px] font-bold text-blue-400">Pr</span>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300 transition-colors ml-1" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Video Export</span>
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
{/* Format selector */}
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-2 block">Format</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => (
|
||||
<button
|
||||
key={codec}
|
||||
onClick={() => handleCodecChange(codec)}
|
||||
className={`p-2.5 rounded-lg border text-center transition-all ${
|
||||
settings.codec === codec
|
||||
? 'border-blue-500 bg-blue-500/10 text-white'
|
||||
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold">{CODEC_INFO[codec].label.split(' / ')[0]}</p>
|
||||
<p className="text-[9px] text-zinc-500 mt-0.5">.{CODEC_INFO[codec].ext}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution & Frame rate row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Resolution</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={`${settings.width}x${settings.height}`}
|
||||
onChange={(e) => {
|
||||
const [w, h] = e.target.value.split('x').map(Number)
|
||||
setSettings(prev => ({ ...prev, width: w, height: h }))
|
||||
}}
|
||||
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
||||
>
|
||||
{RESOLUTIONS.map(r => (
|
||||
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Frame Rate</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={settings.fps}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, fps: parseInt(e.target.value) }))}
|
||||
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
||||
>
|
||||
{FRAME_RATES.map(fps => (
|
||||
<option key={fps} value={fps}>{fps} fps</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Quality</label>
|
||||
{settings.codec === 'h264' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={28}
|
||||
step={1}
|
||||
value={settings.quality}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
|
||||
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
||||
// Note: lower CRF = higher quality (inverted display)
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 w-16 text-right">
|
||||
{settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'}
|
||||
<span className="text-zinc-600 ml-1">({settings.quality})</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.codec === 'prores' && (
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{PRORES_PROFILES.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setSettings(prev => ({ ...prev, quality: p.value }))}
|
||||
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
|
||||
settings.quality === p.value
|
||||
? 'bg-blue-500/20 border border-blue-500 text-blue-300'
|
||||
: 'bg-zinc-800 border border-zinc-700 text-zinc-400 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{settings.codec === 'vp9' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={20}
|
||||
step={1}
|
||||
value={settings.quality}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
|
||||
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 w-20 text-right">
|
||||
{settings.quality} Mbps
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{hasSubtitles && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Options</span>
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={burnSubtitles}
|
||||
onChange={(e) => setBurnSubtitles(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300 group-hover:text-white transition-colors">Burn-in subtitles</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExportVideo}
|
||||
disabled={clips.length === 0 || window.electronAPI.platform === 'web'}
|
||||
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold text-sm flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
Timeline rendering not yet available in web preview
|
||||
</button>
|
||||
|
||||
{clips.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 text-center">Add clips to the timeline to export.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { VideoPreviewPanel } from './VideoPreviewPanel'
|
||||
import { validateVideoSource } from '../lib/video-constraints'
|
||||
|
||||
// Preview-only panel for extend. The direction + seconds-to-add controls live in the
|
||||
// PromptBar (like Video mode), so this just owns the source video + preview.
|
||||
|
||||
interface ExtendPanelProps {
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
fillHeight?: boolean
|
||||
// When the request will hit the cloud API, enforce its input constraints (4K / aspect /
|
||||
// min duration). In local mode resolution is corrected, so these don't block.
|
||||
enforceApiConstraints?: boolean
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
videoDuration: number
|
||||
width: number
|
||||
height: number
|
||||
ready: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function ExtendPanel({
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
fillHeight = false,
|
||||
enforceApiConstraints = true,
|
||||
onChange,
|
||||
}: ExtendPanelProps) {
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
|
||||
setVideoPath(data.videoPath)
|
||||
setVideoDuration(data.videoDuration)
|
||||
setDimensions({ width: data.width, height: data.height })
|
||||
}, [])
|
||||
|
||||
const error = enforceApiConstraints && videoPath
|
||||
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height, ready: !!videoPath && !error })
|
||||
}, [videoPath, videoDuration, dimensions, error, onChange])
|
||||
|
||||
return (
|
||||
<VideoPreviewPanel
|
||||
title="Extend"
|
||||
initialVideoPath={initialVideoPath}
|
||||
initialDuration={initialDuration}
|
||||
resetKey={resetKey}
|
||||
isProcessing={isProcessing}
|
||||
processingStatus={processingStatus}
|
||||
processingDefault="Processing extend..."
|
||||
fillHeight={fillHeight}
|
||||
emptyTitle="Drop a video to extend"
|
||||
hint={{ title: 'Add new frames to your video', subtitle: 'Set direction and length in the bar below, then extend' }}
|
||||
errorMessage={error ?? undefined}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Miriam+Libre:wght@400;700&display=swap');
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes gradientBg {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SHOW_DELAY_MS = 2500
|
||||
|
||||
// Module-level flag — resets on each app launch (page reload)
|
||||
let dismissedThisSession = false
|
||||
|
||||
export function FreeApiKeyBubble({
|
||||
forceApiGenerations,
|
||||
hasLtxApiKey,
|
||||
isGenerating,
|
||||
}: {
|
||||
forceApiGenerations: boolean
|
||||
hasLtxApiKey: boolean
|
||||
isGenerating: boolean
|
||||
}) {
|
||||
const [dismissed, setDismissed] = useState(() => dismissedThisSession)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isGenerating && !dismissed && !forceApiGenerations && !hasLtxApiKey) {
|
||||
timerRef.current = setTimeout(() => setVisible(true), SHOW_DELAY_MS)
|
||||
} else {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
setVisible(false)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isGenerating, dismissed, forceApiGenerations, hasLtxApiKey])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const handleDismiss = () => {
|
||||
dismissedThisSession = true
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-2 rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50 px-4 py-3 text-sm text-zinc-200"
|
||||
style={{ animation: 'fadeInUp 0.3s ease-out' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p>
|
||||
Speed up inference and save memory with free cloud text encoding and
|
||||
prompt enhancement.{' '}
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
Get a free LTX API key
|
||||
</button>{' '}
|
||||
to enable it.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
The free API key covers text encoding and prompt enhancement only.
|
||||
Video generation via API requires a paid plan.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-zinc-700/60 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, X } from 'lucide-react'
|
||||
import type { GenerationError } from '../lib/generation-errors'
|
||||
|
||||
interface GenerationErrorDialogProps {
|
||||
error: GenerationError
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected generation error variant: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
function getGenericHumanMessage(message: string): string {
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('409') || lower.includes('already')) {
|
||||
return 'A generation is already in progress. Please wait for it to finish or cancel it.'
|
||||
}
|
||||
if (lower.includes('cuda') || lower.includes('out of memory') || lower.includes('oom')) {
|
||||
return 'The GPU ran out of memory. Try a lower resolution or shorter duration.'
|
||||
}
|
||||
if ((lower.includes('model') && (lower.includes('not found') || lower.includes('load')))) {
|
||||
return 'The AI model failed to load. Please check your setup and try again.'
|
||||
}
|
||||
if (lower.includes('fetch') || lower.includes('network') || lower.includes('econnrefused')) {
|
||||
return 'Could not connect to the generation server. Make sure the backend is running.'
|
||||
}
|
||||
if (lower.includes('input image') || lower.includes('canvas') || lower.includes('cors')) {
|
||||
return 'Failed to prepare the input image. The file may be corrupted or inaccessible.'
|
||||
}
|
||||
if (lower.includes('could not auto-generate') || lower.includes('send to genspace')) {
|
||||
return message
|
||||
}
|
||||
return 'Something went wrong during generation. Please try again.'
|
||||
}
|
||||
|
||||
function getDialogModel(error: GenerationError): {
|
||||
humanMessage: string
|
||||
technicalDetails: string
|
||||
primaryAction?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
} {
|
||||
switch (error.status) {
|
||||
case 402:
|
||||
switch (error.error.code) {
|
||||
case 'LTX_INSUFFICIENT_FUNDS':
|
||||
return {
|
||||
humanMessage: 'Your LTX API credits are insufficient for this generation. Buy more credits in LTX and try again.',
|
||||
technicalDetails: JSON.stringify(error.error, null, 2),
|
||||
primaryAction: {
|
||||
label: 'Buy Credits',
|
||||
onClick: () => {
|
||||
void window.electronAPI.openLtxBillingPage()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return assertNever(error.error.code)
|
||||
case '4XX':
|
||||
case '5XX':
|
||||
case 'default':
|
||||
return {
|
||||
humanMessage: getGenericHumanMessage(error.error.message),
|
||||
technicalDetails: JSON.stringify(error.error, null, 2),
|
||||
}
|
||||
default:
|
||||
return assertNever(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialogProps) {
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false)
|
||||
const dialogModel = getDialogModel(error)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[480px] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<h2 className="text-base font-semibold text-zinc-100">Generation Failed</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
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>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<p className="text-sm text-zinc-300 leading-relaxed">
|
||||
{dialogModel.humanMessage}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
|
||||
>
|
||||
{detailsExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Technical Details
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<pre className="mt-2 bg-zinc-800/50 rounded-lg p-3 text-[11px] text-zinc-400 whitespace-pre-wrap break-words max-h-40 overflow-auto">
|
||||
{dialogModel.technicalDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center gap-3 ${dialogModel.primaryAction ? 'justify-between' : 'justify-end'}`}>
|
||||
{dialogModel.primaryAction ? (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-4 py-2 bg-zinc-800 text-zinc-100 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
) : null}
|
||||
{dialogModel.primaryAction ? (
|
||||
<button
|
||||
onClick={dialogModel.primaryAction.onClick}
|
||||
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
|
||||
>
|
||||
{dialogModel.primaryAction.label}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
|
||||
|
||||
interface HfModelAccessGateProps {
|
||||
accessMap: ModelAccessMap
|
||||
allAuthorized: boolean
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => void
|
||||
/** When the access check itself failed (network/backend), distinct from unauthorized. */
|
||||
checkError?: string | null
|
||||
onRetryCheck?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HfModelAccessGate({
|
||||
accessMap,
|
||||
allAuthorized,
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
checkError = null,
|
||||
onRetryCheck,
|
||||
className,
|
||||
}: HfModelAccessGateProps) {
|
||||
if (allAuthorized) return null
|
||||
|
||||
if (checkError) {
|
||||
return (
|
||||
<div className={className ?? 'space-y-2'}>
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>Couldn't verify Hugging Face access: {checkError}</span>
|
||||
</div>
|
||||
{onRetryCheck && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetryCheck}
|
||||
className="text-xs"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const unauthorizedRepos = Object.entries(accessMap).filter(([, status]) => status === 'not_authorized')
|
||||
if (unauthorizedRepos.length === 0) return null
|
||||
|
||||
if (hfAuthStatus !== 'authenticated') {
|
||||
return (
|
||||
<div className={className ?? 'space-y-2'}>
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
This model is gated on Hugging Face. Sign in, then accept the license to download.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startHuggingFaceLogin}
|
||||
disabled={hfAuthPolling}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs"
|
||||
>
|
||||
{hfAuthPolling ? 'Waiting for sign in…' : 'Sign in with Hugging Face'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className ?? 'space-y-1.5'}>
|
||||
<p className="text-xs text-amber-400">
|
||||
Accept the Hugging Face license for this model, then download.
|
||||
</p>
|
||||
{unauthorizedRepos.map(([repoId]) => (
|
||||
<div key={repoId} className="flex items-center justify-between gap-2 bg-zinc-900 rounded px-2 py-1.5">
|
||||
<span className="text-[10px] text-zinc-400 font-mono truncate">{repoId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.electronAPI.openHuggingFaceRepo({ repoId })
|
||||
}}
|
||||
className="text-[10px] text-indigo-400 hover:text-indigo-300 font-medium flex-shrink-0"
|
||||
>
|
||||
Request access
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Upload, Loader2, Film, Sparkles, Image as ImageIcon,
|
||||
RefreshCw, Download, AlertCircle, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { OutpaintCanvasEditor, type OutpaintPads } from './OutpaintCanvasEditor'
|
||||
|
||||
export type ICLoraConditioningType = 'canny' | 'depth' | 'custom'
|
||||
|
||||
interface ICLoraPanelProps {
|
||||
initialVideoPath?: string | null
|
||||
resetKey?: number
|
||||
fillHeight?: boolean
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
// IC-LoRA mode: 'image' accepts a still image and skips canny/depth preprocessing
|
||||
// (the IC-LoRA builds the control video server-side). Default 'video' = today's behavior.
|
||||
inputKind?: 'image' | 'video'
|
||||
// IC-LoRA mode: when an IC-LoRA is selected (via the library modal) the panel switches to
|
||||
// IC-LoRA mode and hides the canny/depth conditioning preview. The catalog/download UI
|
||||
// lives in the modal, not here.
|
||||
selectedIcLoraId?: string | null
|
||||
// Catalog IC-LoRA opt-in: when true the middle column offers an optional reference-image
|
||||
// picker (instead of the canny/depth conditioning preview, which catalog IC-LoRAs hide).
|
||||
allowsReferenceImage?: boolean
|
||||
// Outpainting (position_canvas control): when true the middle column shows the canvas editor.
|
||||
showOutpaintCanvas?: boolean
|
||||
outpaintPads?: OutpaintPads
|
||||
onOutpaintPadsChange?: (pads: OutpaintPads) => void
|
||||
// Library browsing (and catalog IC-LoRA generation in general) is local-only.
|
||||
isLocalMode?: boolean
|
||||
onBrowseLibrary?: () => void
|
||||
conditioningType?: ICLoraConditioningType
|
||||
onConditioningTypeChange?: (type: ICLoraConditioningType) => void
|
||||
conditioningStrength?: number
|
||||
onConditioningStrengthChange?: (strength: number) => void
|
||||
outputVideoPath?: string | null
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
conditioningType: ICLoraConditioningType
|
||||
conditioningStrength: number
|
||||
ready: boolean
|
||||
referenceImagePath: string | null
|
||||
// Source pixel dimensions (0 until media loads) — feeds the resolution control.
|
||||
width: number
|
||||
height: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
export const CONDITIONING_TYPES: { value: ICLoraConditioningType; label: string; desc: string }[] = [
|
||||
{ value: 'canny', label: 'Canny Edges', desc: 'Edge detection' },
|
||||
{ value: 'depth', label: 'Depth Map', desc: 'Estimated depth' },
|
||||
{ value: 'custom', label: 'Custom IC-LoRA', desc: 'Your own weights + control video' },
|
||||
]
|
||||
|
||||
type StartModelDownloadBody = NonNullable<ApiRequestBodyOf<'startModelDownload'>>
|
||||
type ModelCheckpointID = NonNullable<StartModelDownloadBody['cp_ids']>[number]
|
||||
type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'>
|
||||
|
||||
|
||||
export function ICLoraPanel({
|
||||
initialVideoPath,
|
||||
resetKey,
|
||||
fillHeight = false,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
inputKind = 'video',
|
||||
selectedIcLoraId = null,
|
||||
allowsReferenceImage = false,
|
||||
showOutpaintCanvas = false,
|
||||
outpaintPads,
|
||||
onOutpaintPadsChange,
|
||||
isLocalMode = false,
|
||||
onBrowseLibrary,
|
||||
conditioningType: conditioningTypeProp,
|
||||
onConditioningTypeChange,
|
||||
conditioningStrength: conditioningStrengthProp,
|
||||
onConditioningStrengthChange,
|
||||
outputVideoPath: _outputVideoPath,
|
||||
onChange,
|
||||
}: ICLoraPanelProps) {
|
||||
const inputVideoRef = useRef<HTMLVideoElement>(null)
|
||||
const [inputVideoPath, setInputVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const inputVideoUrl = inputVideoPath ? pathToFileUrl(inputVideoPath) : null
|
||||
const [inputTime, setInputTime] = useState(0)
|
||||
// Source pixel dimensions, read off the loaded media — drives the outpaint canvas editor.
|
||||
const [sourceDims, setSourceDims] = useState<{ w: number; h: number } | null>(null)
|
||||
|
||||
// Optional reference image for catalog IC-LoRAs that allow it (frame 0, strength 1.0).
|
||||
const [referenceImagePath, setReferenceImagePath] = useState<string | null>(null)
|
||||
const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null
|
||||
const [isReferenceDragOver, setIsReferenceDragOver] = useState(false)
|
||||
|
||||
const [internalCondType, setInternalCondType] = useState<ICLoraConditioningType>('canny')
|
||||
const [internalCondStrength, setInternalCondStrength] = useState(1.0)
|
||||
const conditioningType = conditioningTypeProp ?? internalCondType
|
||||
const conditioningStrength = conditioningStrengthProp ?? internalCondStrength
|
||||
// Custom mode: the user supplies their own IC-LoRA + a pre-rendered control video,
|
||||
// so there's no canny/depth preprocessing to preview.
|
||||
const isCustom = conditioningType === 'custom'
|
||||
// Image (IC-LoRA) mode: no canny/depth preprocessing, no bundled-cp download gate.
|
||||
const isImage = inputKind === 'image'
|
||||
// Catalog IC-LoRA mode (any input kind): the IC-LoRA builds its own control video server-side.
|
||||
const isCatalogIcLora = selectedIcLoraId !== null
|
||||
// The conditioning preview only applies to the built-in canny/depth flow.
|
||||
const showConditioningPreview = !isCatalogIcLora && !isCustom
|
||||
const [conditioningPreview, setConditioningPreview] = useState<string | null>(null)
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
|
||||
const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState<ModelCheckpointID[]>([])
|
||||
const [icLoraSupported, setIcLoraSupported] = useState(true)
|
||||
const [isCheckingIcLora, setIsCheckingIcLora] = useState(false)
|
||||
const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [extractError, setExtractError] = useState<string | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const icLoraReady = icLoraSupported && requiredIcLoraCpIds.length === 0
|
||||
|
||||
// Switching to an entry with a different input.kind (image vs video) invalidates the loaded
|
||||
// input — clear it so a stale video can't be submitted to an image-input entry (backend 400).
|
||||
const prevInputKindRef = useRef(inputKind)
|
||||
useEffect(() => {
|
||||
if (prevInputKindRef.current === inputKind) return
|
||||
prevInputKindRef.current = inputKind
|
||||
setInputVideoPath(null)
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}, [inputKind])
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setInputVideoPath(initialVideoPath || null)
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setInternalCondType('canny')
|
||||
setInternalCondStrength(1.0)
|
||||
onConditioningTypeChange?.('canny')
|
||||
onConditioningStrengthChange?.(1.0)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
setReferenceImagePath(null)
|
||||
}, [resetKey, initialVideoPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
// Custom brings its own IC-LoRA + control video; recipes (any input kind) build the
|
||||
// control video server-side. Neither needs the bundled canny/depth cps.
|
||||
const ready = !!inputVideoPath && (isCustom || isImage || isCatalogIcLora || icLoraReady)
|
||||
onChange?.({
|
||||
videoPath: inputVideoPath,
|
||||
conditioningType,
|
||||
conditioningStrength,
|
||||
ready,
|
||||
referenceImagePath,
|
||||
width: sourceDims?.w ?? 0,
|
||||
height: sourceDims?.h ?? 0,
|
||||
})
|
||||
}, [inputVideoUrl, inputVideoPath, conditioningType, conditioningStrength, isCustom, isImage, isCatalogIcLora, icLoraReady, referenceImagePath, sourceDims, onChange])
|
||||
|
||||
const checkIcLoraAvailability = useCallback(async () => {
|
||||
setIsCheckingIcLora(true)
|
||||
const result = await ApiClient.getLtxIcLoraRecommendation()
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to fetch IC-LoRA model status: ${result.error.message}`)
|
||||
setDownloadError(result.error.message)
|
||||
setIsCheckingIcLora(false)
|
||||
return
|
||||
}
|
||||
|
||||
const recommendationPayload = result.data
|
||||
setRequiredIcLoraCpIds(recommendationPayload.cps_to_download)
|
||||
setIcLoraSupported(recommendationPayload.supported)
|
||||
const isReady = recommendationPayload.supported && recommendationPayload.cps_to_download.length === 0
|
||||
|
||||
if (isReady) {
|
||||
setIsDownloadingIcLora(false)
|
||||
setDownloadProgress(null)
|
||||
setDownloadError(null)
|
||||
setDownloadSessionId(null)
|
||||
}
|
||||
setIsCheckingIcLora(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void checkIcLoraAvailability()
|
||||
}, [checkIcLoraAvailability])
|
||||
|
||||
useEffect(() => {
|
||||
if (icLoraReady || !isDownloadingIcLora || !downloadSessionId) return
|
||||
|
||||
const pollProgress = async () => {
|
||||
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed polling IC-LoRA download progress: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const progressPayload = result.data
|
||||
setDownloadProgress(progressPayload)
|
||||
|
||||
if (progressPayload.status === 'error') {
|
||||
setIsDownloadingIcLora(false)
|
||||
setDownloadError(progressPayload.error || 'Model download failed')
|
||||
return
|
||||
}
|
||||
|
||||
if (progressPayload.status === 'complete') {
|
||||
setIsDownloadingIcLora(false)
|
||||
await checkIcLoraAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
void pollProgress()
|
||||
const interval = setInterval(() => { void pollProgress() }, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [icLoraReady, isDownloadingIcLora, downloadSessionId, checkIcLoraAvailability])
|
||||
|
||||
const handleDownloadIcLora = useCallback(async () => {
|
||||
if (isDownloadingIcLora) return
|
||||
setDownloadError(null)
|
||||
|
||||
const result = await ApiClient.startModelDownload({
|
||||
type: 'download',
|
||||
cp_ids: [...requiredIcLoraCpIds],
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to start IC-LoRA download: ${result.error.message}`)
|
||||
setDownloadError(result.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const startedPayload = result.data
|
||||
if (startedPayload.status === 'started') {
|
||||
setDownloadSessionId(startedPayload.sessionId)
|
||||
setIsDownloadingIcLora(true)
|
||||
return
|
||||
}
|
||||
|
||||
setDownloadError('Unexpected response while starting IC-LoRA download')
|
||||
}, [isDownloadingIcLora, requiredIcLoraCpIds])
|
||||
|
||||
const isExtractingRef = useRef(false)
|
||||
const extractConditioning = useCallback(async () => {
|
||||
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || isExtractingRef.current || !icLoraReady) return
|
||||
isExtractingRef.current = true
|
||||
setIsExtracting(true)
|
||||
setExtractError(null)
|
||||
const result = await ApiClient.extractIcLoraConditioning({
|
||||
video_path: inputVideoPath,
|
||||
conditioning_type: conditioningType,
|
||||
frame_time: inputTime,
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to extract conditioning: ${result.error.message}`)
|
||||
setExtractError(result.error.message)
|
||||
isExtractingRef.current = false
|
||||
setIsExtracting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setConditioningPreview(result.data.conditioning)
|
||||
isExtractingRef.current = false
|
||||
setIsExtracting(false)
|
||||
}, [inputVideoPath, conditioningType, inputTime, icLoraReady, isCustom, isImage, isCatalogIcLora])
|
||||
|
||||
const extractTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || !icLoraReady) return
|
||||
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
|
||||
extractTimerRef.current = setTimeout(() => {
|
||||
void extractConditioning()
|
||||
}, 300)
|
||||
return () => {
|
||||
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
|
||||
}
|
||||
}, [inputTime, conditioningType, inputVideoPath, icLoraReady, extractConditioning])
|
||||
|
||||
useEffect(() => {
|
||||
const video = inputVideoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setInputTime(video.currentTime)
|
||||
const onSeeked = () => setInputTime(video.currentTime)
|
||||
video.addEventListener('timeupdate', onTime)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', onTime)
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
}
|
||||
}, [inputVideoUrl, icLoraReady, isCheckingIcLora])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog(
|
||||
isImage
|
||||
? { title: 'Select Input Image', filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] }
|
||||
: { title: 'Select Driving Video', filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }] },
|
||||
)
|
||||
if (paths && paths.length > 0) {
|
||||
const filePath = paths[0]
|
||||
setInputVideoPath(filePath)
|
||||
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}
|
||||
}, [isImage])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInputVideoPath(null)
|
||||
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const acceptedAssetType = isImage ? 'image' : 'video'
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === acceptedAssetType && asset.path) {
|
||||
setInputVideoPath(asset.path)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) {
|
||||
setInputVideoPath(filePath)
|
||||
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}
|
||||
}
|
||||
}, [isImage])
|
||||
|
||||
const handleBrowseReference = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Select Reference Image',
|
||||
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }],
|
||||
})
|
||||
if (paths && paths.length > 0) {
|
||||
setReferenceImagePath(paths[0])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClearReference = useCallback(() => {
|
||||
setReferenceImagePath(null)
|
||||
}, [])
|
||||
|
||||
const handleDropReference = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsReferenceDragOver(false)
|
||||
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === 'image' && asset.path) {
|
||||
setReferenceImagePath(asset.path)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) setReferenceImagePath(filePath)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only the built-in canny/depth flow needs the bundled preprocessing cps. Recipes
|
||||
// (any input kind) and custom build their own control video, so never gate them.
|
||||
const showBuiltinGate =
|
||||
!isCustom && !isImage && !isCatalogIcLora && (isCheckingIcLora || !icLoraReady)
|
||||
const showUnsupportedGate = showBuiltinGate && !isCheckingIcLora && !icLoraSupported
|
||||
const showDownloadGate = showBuiltinGate && !showUnsupportedGate
|
||||
const runningDownloadProgress =
|
||||
downloadProgress?.status === 'downloading' ? downloadProgress : null
|
||||
const gateItemIds = [...new Set([...(requiredIcLoraCpIds ?? []), ...(runningDownloadProgress?.all_files ?? [])])]
|
||||
const gateItems = gateItemIds.map((cpId) => {
|
||||
const downloaded = !requiredIcLoraCpIds.includes(cpId)
|
||||
const isCompleted = runningDownloadProgress?.completed_files?.includes(cpId) ?? false
|
||||
const isCurrentDownload = isDownloadingIcLora && runningDownloadProgress?.current_downloading_file === cpId
|
||||
const progress = downloaded ? 100 : (isCompleted ? 100 : (isCurrentDownload ? (runningDownloadProgress?.current_file_progress ?? 0) : 0))
|
||||
const status = downloaded ? 'Ready' : (isCompleted ? 'Complete' : (isCurrentDownload ? 'Downloading' : 'Missing'))
|
||||
return { id: cpId, label: cpId, downloaded, progress, status }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-white">IC-LoRA / Style Transfer</span>
|
||||
</div>
|
||||
{inputVideoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Clear video"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Replace video"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocalMode && (
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBrowseLibrary}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-400" /> Browse IC-LoRAs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsupportedGate ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
|
||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-amber-600/20 flex items-center justify-center mt-0.5">
|
||||
<AlertCircle className="h-4 w-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white">Built-in control needs LTX 2.3</h3>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Depth and canny Union Control are not available on the active LTX 2.5 model.
|
||||
Switch to an LTX 2.3 local model in Settings, or use a custom / catalog IC-LoRA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showDownloadGate ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
|
||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-blue-600/20 flex items-center justify-center mt-0.5">
|
||||
<Download className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white">Download Required: IC-LoRA Resources</h3>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Editing is locked until all IC-LoRA preprocessing models are available locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{isCheckingIcLora ? (
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-300">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
|
||||
Checking model availability...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{gateItems.map(item => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-2">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="text-zinc-300">{item.label}</span>
|
||||
<span className={item.downloaded ? 'text-blue-400' : 'text-zinc-500'}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-300 bg-blue-500"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{item.progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{downloadError && (
|
||||
<div className="text-[11px] text-red-400">{downloadError}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleDownloadIcLora}
|
||||
disabled={isDownloadingIcLora}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDownloadingIcLora ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{downloadError ? 'Retry Download' : 'Download Models'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void checkIcLoraAvailability() }}
|
||||
disabled={isCheckingIcLora}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-600 text-zinc-300 hover:text-white hover:border-zinc-500 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isCheckingIcLora ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Input</span>
|
||||
{inputVideoPath && (
|
||||
<span className="text-[10px] text-zinc-500 truncate min-w-0">
|
||||
{inputVideoPath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!inputVideoUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{inputVideoUrl ? (
|
||||
isImage ? (
|
||||
<img
|
||||
src={inputVideoUrl}
|
||||
alt="Input"
|
||||
className="w-full h-full object-contain"
|
||||
onLoad={(e) => setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={inputVideoRef}
|
||||
src={inputVideoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
controls
|
||||
onLoadedMetadata={(e) => setSourceDims({ w: e.currentTarget.videoWidth, h: e.currentTarget.videoHeight })}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
|
||||
<Film className="h-6 w-6 text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-zinc-400 text-xs">{isImage ? 'Drop or import an image' : isCustom ? 'Drop or import a control video' : 'Drop or import a driving video'}</p>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
|
||||
>
|
||||
{isImage ? 'Import Image' : 'Import Video'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Conditioning</span>
|
||||
<button
|
||||
onClick={() => { void extractConditioning() }}
|
||||
disabled={!inputVideoPath || isExtracting}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isExtracting ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
|
||||
{isExtracting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{conditioningPreview ? (
|
||||
<img src={conditioningPreview} alt="Conditioning preview" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-zinc-600 text-xs">
|
||||
{inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */}
|
||||
{allowsReferenceImage && !showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Reference</span>
|
||||
{referenceImagePath && (
|
||||
<span className="text-[10px] text-zinc-500 truncate min-w-0">
|
||||
{referenceImagePath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
{referenceImagePath ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleClearReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Clear reference image"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Replace reference image"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!referenceImageUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isReferenceDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsReferenceDragOver(true) }}
|
||||
onDragLeave={() => setIsReferenceDragOver(false)}
|
||||
onDrop={handleDropReference}
|
||||
>
|
||||
{referenceImageUrl ? (
|
||||
<img src={referenceImageUrl} alt="Reference" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
|
||||
<ImageIcon className="h-6 w-6 text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-zinc-400 text-xs">Drop or import a reference image</p>
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
|
||||
>
|
||||
Import Image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */}
|
||||
{showOutpaintCanvas && !showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Canvas</span>
|
||||
</div>
|
||||
{sourceDims && outpaintPads ? (
|
||||
<OutpaintCanvasEditor
|
||||
sourceWidth={sourceDims.w}
|
||||
sourceHeight={sourceDims.h}
|
||||
value={outpaintPads}
|
||||
onChange={(p) => onOutpaintPadsChange?.(p)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 bg-black flex items-center justify-center">
|
||||
<p className="text-zinc-600 text-xs">Import a video to set the outpaint canvas</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output column */}
|
||||
<div className="flex-1 flex flex-col border-l border-zinc-800 min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Output</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
|
||||
{_outputVideoPath ? (
|
||||
<video
|
||||
src={pathToFileUrl(_outputVideoPath)}
|
||||
className="w-full h-full object-contain"
|
||||
controls
|
||||
/>
|
||||
) : isProcessing ? (
|
||||
<div className="text-center p-4">
|
||||
<Loader2 className="h-6 w-6 text-blue-400 animate-spin mx-auto mb-2" />
|
||||
<p className="text-zinc-400 text-xs">{processingStatus || 'Generating...'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-zinc-600 text-xs">Output video will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractError && (
|
||||
<div className="px-4 py-3 border-t border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{extractError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { SettingsDropdown } from './SettingsDropdown'
|
||||
import type { IcLoraControlsProps } from './IcLoraSettingsControls'
|
||||
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
|
||||
|
||||
// One labelled row: caption on the left, the dropdown on the right.
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wide shrink-0">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const triggerValue = (text: string) => (
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium">{text}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
)
|
||||
|
||||
// Advanced IC-LoRA controls, in a side panel beside the prompt bar (so they don't push the
|
||||
// Generate button off the bottom row). Reuses the shared IcLoraControlsProps bag.
|
||||
export function IcLoraAdvancedPanel({
|
||||
icLoraSkipStage2,
|
||||
onIcLoraSkipStage2Change,
|
||||
icLoraUseLoraInStage2,
|
||||
onIcLoraUseLoraInStage2Change,
|
||||
icLoraResolutionOptions,
|
||||
icLoraResolutionKey,
|
||||
onIcLoraResolutionKeyChange,
|
||||
icLoraResolutionFactor,
|
||||
onIcLoraResolutionFactorChange,
|
||||
icLoraAudioMode,
|
||||
onIcLoraAudioModeChange,
|
||||
icLoraFps,
|
||||
onIcLoraFpsChange,
|
||||
advancedIcLoraControls,
|
||||
}: IcLoraControlsProps) {
|
||||
// Advanced-only knobs. (LoRA strength is a regular control — it lives in the bottom bar.)
|
||||
if (!advancedIcLoraControls) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/90 backdrop-blur p-1.5 flex flex-col gap-0.5 w-40 text-xs">
|
||||
<span className="text-[9px] font-semibold text-zinc-500 uppercase tracking-wider px-1">Advanced</span>
|
||||
|
||||
<Row label="Stage 2">
|
||||
<SettingsDropdown
|
||||
title="STAGE 2 REFINE"
|
||||
value={icLoraSkipStage2 ? 'skip' : 'keep'}
|
||||
onChange={(v) => onIcLoraSkipStage2Change?.(v === 'skip')}
|
||||
options={[
|
||||
{ value: 'keep', label: 'On (two-stage)' },
|
||||
{ value: 'skip', label: 'Off (stage 1 only)' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraSkipStage2 ? 'Off' : 'On')}
|
||||
/>
|
||||
</Row>
|
||||
{/* Always shown. Only takes effect when Stage 2 runs (Stage 2 = On); it's a
|
||||
no-op when Stage 2 is skipped, but kept visible for discoverability. */}
|
||||
<Row label="LoRA in S2">
|
||||
<SettingsDropdown
|
||||
title="IC-LORA IN STAGE 2"
|
||||
value={icLoraUseLoraInStage2 ? 'on' : 'off'}
|
||||
onChange={(v) => onIcLoraUseLoraInStage2Change?.(v === 'on')}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off (refine from prompt)' },
|
||||
{ value: 'on', label: 'On (keep effect, source res)' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraUseLoraInStage2 ? 'On' : 'Off')}
|
||||
/>
|
||||
</Row>
|
||||
{!icLoraSkipStage2 && icLoraUseLoraInStage2 && (icLoraResolutionOptions?.length ?? 0) > 1 && (
|
||||
<Row label="Res">
|
||||
<SettingsDropdown
|
||||
title="RESOLUTION"
|
||||
value={icLoraResolutionKey ?? 'original'}
|
||||
onChange={(v) => onIcLoraResolutionKeyChange?.(v)}
|
||||
options={icLoraResolutionOptions!.map((o) => ({ value: o.key, label: o.label }))}
|
||||
trigger={triggerValue(
|
||||
(icLoraResolutionOptions!.find((o) => o.key === icLoraResolutionKey)?.label ?? 'Original').split(' ')[0],
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{icLoraSkipStage2 && (
|
||||
<Row label="Res">
|
||||
<SettingsDropdown
|
||||
title="RES FACTOR"
|
||||
value={String(icLoraResolutionFactor ?? 2.0)}
|
||||
onChange={(v) => onIcLoraResolutionFactorChange?.(parseFloat(v))}
|
||||
options={[
|
||||
// 0 is the "source dimensions" sentinel — output matches the input
|
||||
// resolution and the multiplier is ignored (see ic_lora_handler).
|
||||
{ value: '0', label: 'Source dimensions' },
|
||||
{ value: '1', label: '1.00 (half)' },
|
||||
{ value: '1.25', label: '1.25' },
|
||||
{ value: '1.5', label: '1.50' },
|
||||
{ value: '1.75', label: '1.75' },
|
||||
{ value: '2', label: '2.00 (native)' },
|
||||
]}
|
||||
trigger={triggerValue(
|
||||
icLoraResolutionFactor === 0 ? 'Source' : `×${(icLoraResolutionFactor ?? 2.0).toFixed(2)}`,
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Audio">
|
||||
<SettingsDropdown
|
||||
title="AUDIO"
|
||||
value={icLoraAudioMode ?? 'generated'}
|
||||
onChange={(v) => onIcLoraAudioModeChange?.(v as IcLoraAudioMode)}
|
||||
options={[
|
||||
{ value: 'generated', label: 'Generated' },
|
||||
{ value: 'source', label: 'Source' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
trigger={triggerValue((icLoraAudioMode ?? 'generated').replace(/^\w/, (c: string) => c.toUpperCase()))}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="FPS">
|
||||
<SettingsDropdown
|
||||
title="FPS"
|
||||
value={icLoraFps == null ? 'source' : String(icLoraFps)}
|
||||
onChange={(v) => onIcLoraFpsChange?.(v === 'source' ? null : parseFloat(v))}
|
||||
options={[
|
||||
{ value: 'source', label: 'Source' },
|
||||
{ value: '24', label: '24 fps' },
|
||||
{ value: '16', label: '16 fps' },
|
||||
{ value: '12', label: '12 fps' },
|
||||
{ value: '8', label: '8 fps' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraFps == null ? 'Src' : String(icLoraFps))}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Fragment } from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { SettingsDropdown } from './SettingsDropdown'
|
||||
import { Tooltip } from './ui/tooltip'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
import type { ICLoraConditioningType } from './ICLoraPanel'
|
||||
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
|
||||
import type { ResolutionOption } from '../lib/video-resolution'
|
||||
|
||||
type CatalogControl = components['schemas']['IcLoraControl']
|
||||
|
||||
// Short hover/focus help per IC-LoRA control. The catalog-driven controls are keyed by control id.
|
||||
const CONTROL_TOOLTIPS: Record<string, string> = {
|
||||
condStrength: 'How strongly the conditioning (edges/depth) constrains the result. Higher follows the control signal more closely.',
|
||||
loraStrength: 'How strongly the IC-LoRA weights are applied. Lower is subtler, higher is stronger.',
|
||||
duration: 'Length of the generated clip.',
|
||||
}
|
||||
|
||||
// A catalog control's display label for a given option: explicit value_labels win, else value + unit.
|
||||
function controlOptionLabel(control: CatalogControl, option: number | string): string {
|
||||
return control.value_labels?.[String(option)] ?? `${option}${control.unit ?? ''}`
|
||||
}
|
||||
|
||||
// The IC-LoRA control row in the prompt bar: conditioning-type selector + per-mode knobs
|
||||
// (strength, duration, LoRA strength, and the advanced stage-2 / resolution / audio / fps).
|
||||
export interface IcLoraControlsProps {
|
||||
icLoraCondType?: ICLoraConditioningType
|
||||
icLoraSelectorValue?: string
|
||||
icLoraSelectorOptions?: { value: string; label: string }[]
|
||||
onIcLoraSelectorChange?: (value: string) => void
|
||||
icLoraStrength?: number
|
||||
onIcLoraStrengthChange?: (strength: number) => void
|
||||
availableIcLoras?: ApiSuccessOf<'listModels'>['models']
|
||||
icLoraCustomRef?: string | null
|
||||
onIcLoraCustomRefChange?: (ref: string | null) => void
|
||||
icLoraSkipStage2?: boolean
|
||||
onIcLoraSkipStage2Change?: (skip: boolean) => void
|
||||
icLoraUseLoraInStage2?: boolean
|
||||
onIcLoraUseLoraInStage2Change?: (use: boolean) => void
|
||||
// Resolution tiers for the use_lora_in_stage_2 path (empty until source media loads).
|
||||
icLoraResolutionOptions?: ResolutionOption[]
|
||||
icLoraResolutionKey?: string
|
||||
onIcLoraResolutionKeyChange?: (key: string) => void
|
||||
icLoraResolutionFactor?: number
|
||||
onIcLoraResolutionFactorChange?: (factor: number) => void
|
||||
icLoraAudioMode?: IcLoraAudioMode
|
||||
onIcLoraAudioModeChange?: (mode: IcLoraAudioMode) => void
|
||||
icLoraLoraStrength?: number
|
||||
onIcLoraLoraStrengthChange?: (strength: number) => void
|
||||
icLoraFps?: number | null
|
||||
onIcLoraFpsChange?: (fps: number | null) => void
|
||||
isCatalogIcLora?: boolean
|
||||
advancedIcLoraControls?: boolean
|
||||
// Catalog-declared controls for the selected IC-LoRA, rendered generically by id + kind.
|
||||
controls?: CatalogControl[]
|
||||
controlValues?: Record<string, number | string>
|
||||
onControlChange?: (id: string, value: number | string) => void
|
||||
}
|
||||
|
||||
export function IcLoraSettingsControls({
|
||||
icLoraCondType,
|
||||
icLoraSelectorValue,
|
||||
icLoraSelectorOptions,
|
||||
onIcLoraSelectorChange,
|
||||
icLoraStrength,
|
||||
onIcLoraStrengthChange,
|
||||
availableIcLoras,
|
||||
icLoraCustomRef,
|
||||
onIcLoraCustomRefChange,
|
||||
icLoraLoraStrength,
|
||||
onIcLoraLoraStrengthChange,
|
||||
isCatalogIcLora,
|
||||
controls,
|
||||
controlValues,
|
||||
onControlChange,
|
||||
}: IcLoraControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Unified IC-LoRA selector: canny | depth | [catalog IC-LoRAs] | custom. No tooltip:
|
||||
the options are self-explanatory and the copy was wrong for catalog recipes. */}
|
||||
<SettingsDropdown
|
||||
title="CONDITIONING TYPE"
|
||||
value={icLoraSelectorValue ?? 'canny'}
|
||||
onChange={(v) => onIcLoraSelectorChange?.(v)}
|
||||
options={icLoraSelectorOptions ?? []}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium">{(icLoraSelectorOptions ?? []).find(o => o.value === icLoraSelectorValue)?.label ?? 'Canny Edges'}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!isCatalogIcLora && icLoraCondType === 'custom' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title="IC-LORA"
|
||||
value={icLoraCustomRef ?? ''}
|
||||
onChange={(v) => onIcLoraCustomRefChange?.(v || null)}
|
||||
options={(availableIcLoras ?? []).map(m => ({ value: m.path, label: m.name }))}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium truncate max-w-[160px]">
|
||||
{availableIcLoras?.find(m => m.path === icLoraCustomRef)?.name
|
||||
?? (availableIcLoras?.length ? 'Select IC-LoRA' : 'No IC-LoRAs found')}
|
||||
</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isCatalogIcLora && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title="STRENGTH"
|
||||
tooltip={CONTROL_TOOLTIPS.condStrength}
|
||||
value={String(icLoraStrength ?? 1.0)}
|
||||
onChange={(v) => onIcLoraStrengthChange?.(parseFloat(v))}
|
||||
options={[
|
||||
{ value: '0.5', label: '0.50' },
|
||||
{ value: '0.75', label: '0.75' },
|
||||
{ value: '1', label: '1.00' },
|
||||
{ value: '1.25', label: '1.25' },
|
||||
{ value: '1.5', label: '1.50' },
|
||||
{ value: '2', label: '2.00' },
|
||||
]}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-500 text-[10px]">STR</span>
|
||||
<span className="text-zinc-300 font-medium">{(icLoraStrength ?? 1.0).toFixed(2)}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Catalog-declared option controls (duration, …) rendered generically as dropdowns.
|
||||
Structured controls (position_canvas) carry no options and render elsewhere — skip them. */}
|
||||
{isCatalogIcLora && (controls ?? []).filter((c) => c.options != null).map((c) => {
|
||||
const current = controlValues?.[c.id] ?? c.default ?? ''
|
||||
const title = c.label.toUpperCase()
|
||||
return (
|
||||
<Fragment key={c.id}>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title={title}
|
||||
tooltip={CONTROL_TOOLTIPS[c.id]}
|
||||
value={String(current)}
|
||||
onChange={(v) => onControlChange?.(c.id, c.kind === 'int' ? parseInt(v, 10) : v)}
|
||||
options={(c.options as (number | string)[]).map((o) => ({ value: String(o), label: controlOptionLabel(c, o) }))}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-500 text-[10px]">{title}</span>
|
||||
<span className="text-zinc-300 font-medium">{controlOptionLabel(c, current)}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{/* LoRA strength: a regular control for every IC-LoRA mode (canny/depth/custom/catalog).
|
||||
It scales the adapter merge weight, so it always has an effect. (The advanced knobs —
|
||||
S2/Res/Audio/FPS — live in IcLoraAdvancedPanel beside the prompt bar.) A continuous
|
||||
slider (like the plain-LoRA strength bar) rather than a dropdown, for finer control. */}
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<Tooltip content={CONTROL_TOOLTIPS.loraStrength}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<span className="text-zinc-500 text-[10px]">LORA</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={icLoraLoraStrength ?? 1.0}
|
||||
onChange={(e) => onIcLoraLoraStrengthChange?.(parseFloat(e.target.value))}
|
||||
className="w-16 h-1 accent-white cursor-pointer"
|
||||
aria-label="LoRA strength"
|
||||
/>
|
||||
<span className="text-zinc-300 font-medium w-9 text-right">{(icLoraLoraStrength ?? 1.0).toFixed(2)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { logger } from '../lib/logger'
|
||||
import { addGenericAssetToProject, addVisualAssetToProject } from '../lib/asset-copy'
|
||||
import {
|
||||
X, FileVideo, FileAudio, Image, Check, AlertTriangle,
|
||||
FolderOpen, RefreshCw, Loader2, FileText, Link2,
|
||||
ChevronDown, ChevronRight, Upload
|
||||
} from 'lucide-react'
|
||||
import type { ParsedTimeline, ParsedMediaRef } from '../lib/timeline-import'
|
||||
import { parseTimelineXml } from '../lib/timeline-import'
|
||||
import { selectShowImportTimelineModal } from '../views/editor/editor-selectors'
|
||||
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
|
||||
|
||||
interface ImportTimelineModalProps {
|
||||
projectId: string | null
|
||||
}
|
||||
|
||||
type ImportStep = 'select' | 'parsing' | 'relink' | 'error'
|
||||
type MediaImportStatus = 'missing' | 'ready' | 'copying' | 'copied' | 'skipped' | 'error'
|
||||
|
||||
interface ImportProgressState {
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
currentName: string
|
||||
}
|
||||
|
||||
function getFilenameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || ''
|
||||
}
|
||||
|
||||
export function ImportTimelineModal({ projectId }: ImportTimelineModalProps) {
|
||||
const { closeImportTimelineModal, importParsedTimeline } = useEditorActions()
|
||||
const isOpen = useEditorStore(selectShowImportTimelineModal)
|
||||
const [step, setStep] = useState<ImportStep>('select')
|
||||
const [parsedTimeline, setParsedTimeline] = useState<ParsedTimeline | null>(null)
|
||||
const [mediaRefs, setMediaRefs] = useState<ParsedMediaRef[]>([])
|
||||
const [mediaStatus, setMediaStatus] = useState<Record<string, MediaImportStatus>>({})
|
||||
const [error, setError] = useState<string>('')
|
||||
const [importError, setImportError] = useState<string>('')
|
||||
const [expandedInfo, setExpandedInfo] = useState(true)
|
||||
const [expandedMedia, setExpandedMedia] = useState(true)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importProgress, setImportProgress] = useState<ImportProgressState>({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
currentName: '',
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
closeImportTimelineModal()
|
||||
}, [closeImportTimelineModal])
|
||||
|
||||
// Reset when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setStep('select')
|
||||
setParsedTimeline(null)
|
||||
setMediaRefs([])
|
||||
setMediaStatus({})
|
||||
setError('')
|
||||
setImportError('')
|
||||
setIsChecking(false)
|
||||
setIsSearching(false)
|
||||
setIsImporting(false)
|
||||
setImportProgress({ total: 0, completed: 0, skipped: 0, currentName: '' })
|
||||
}, [isOpen])
|
||||
|
||||
const applyAvailabilityToStatus = useCallback((refs: ParsedMediaRef[], availabilityById: Record<string, boolean>) => {
|
||||
const nextStatus: Record<string, MediaImportStatus> = {}
|
||||
for (const ref of refs) {
|
||||
nextStatus[ref.id] = availabilityById[ref.id] ? 'ready' : 'missing'
|
||||
}
|
||||
setMediaStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
// Check file existence for all media refs
|
||||
const checkMediaFiles = useCallback(async (refs: ParsedMediaRef[]): Promise<Record<string, boolean>> => {
|
||||
const availabilityById: Record<string, boolean> = {}
|
||||
if (!window.electronAPI?.checkFilesExist) {
|
||||
refs.forEach(ref => {
|
||||
availabilityById[ref.id] = false
|
||||
})
|
||||
return availabilityById
|
||||
}
|
||||
|
||||
setIsChecking(true)
|
||||
try {
|
||||
const uniquePaths = Array.from(new Set(
|
||||
refs
|
||||
.map(r => r.path?.trim())
|
||||
.filter((p): p is string => Boolean(p))
|
||||
))
|
||||
|
||||
const results = await window.electronAPI.checkFilesExist({ filePaths: uniquePaths })
|
||||
refs.forEach(ref => {
|
||||
const path = ref.path?.trim()
|
||||
availabilityById[ref.id] = path ? (results[path] || false) : false
|
||||
})
|
||||
return availabilityById
|
||||
} catch (err) {
|
||||
logger.error(`Error checking files: ${err}`)
|
||||
refs.forEach(ref => {
|
||||
availabilityById[ref.id] = false
|
||||
})
|
||||
return availabilityById
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelected = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const filename = file.name
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// Check for AAF
|
||||
if (ext === 'aaf') {
|
||||
setError(
|
||||
'AAF files cannot be imported directly.\n\n' +
|
||||
'Please export your timeline as FCP 7 XML from your editing software:\n\n' +
|
||||
' Premiere Pro: File → Export → Final Cut Pro XML\n' +
|
||||
' DaVinci Resolve: File → Export Timeline → FCP 7 XML\n' +
|
||||
' Avid Media Composer: File → Export → FCP 7 XML'
|
||||
)
|
||||
setStep('error')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('parsing')
|
||||
setImportError('')
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
const timeline = parseTimelineXml(content, filename)
|
||||
|
||||
if (!timeline) {
|
||||
throw new Error('Could not parse timeline from file')
|
||||
}
|
||||
|
||||
setParsedTimeline(timeline)
|
||||
setMediaRefs(timeline.mediaRefs)
|
||||
|
||||
const availabilityById = await checkMediaFiles(timeline.mediaRefs)
|
||||
applyAvailabilityToStatus(timeline.mediaRefs, availabilityById)
|
||||
setStep('relink')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setStep('error')
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [applyAvailabilityToStatus, checkMediaFiles])
|
||||
|
||||
// Relink a single media file
|
||||
const handleRelinkFile = useCallback(async (mediaRefId: string) => {
|
||||
if (isImporting || !window.electronAPI?.showOpenFileDialog) return
|
||||
|
||||
const filePaths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Relink Media File',
|
||||
filters: [
|
||||
{ name: 'Media Files', extensions: ['mp4', 'mov', 'avi', 'mkv', 'mxf', 'mp3', 'wav', 'aac', 'flac', 'jpg', 'jpeg', 'png', 'tiff', 'exr', 'dpx'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePaths || filePaths.length === 0) return
|
||||
|
||||
const newPath = filePaths[0]
|
||||
setImportError('')
|
||||
setMediaRefs(prev => prev.map(r =>
|
||||
r.id === mediaRefId
|
||||
? { ...r, path: newPath }
|
||||
: r
|
||||
))
|
||||
setMediaStatus(prev => ({ ...prev, [mediaRefId]: 'ready' }))
|
||||
}, [isImporting])
|
||||
|
||||
// Search a directory for all missing files
|
||||
const handleSearchDirectory = useCallback(async () => {
|
||||
if (isImporting || !window.electronAPI?.showOpenDirectoryDialog) return
|
||||
|
||||
const dir = await window.electronAPI.showOpenDirectoryDialog({ title: 'Select folder to search for media files' })
|
||||
if (!dir) return
|
||||
|
||||
setIsSearching(true)
|
||||
setImportError('')
|
||||
|
||||
try {
|
||||
const missingRefs = mediaRefs.filter(r => mediaStatus[r.id] === 'missing')
|
||||
if (missingRefs.length === 0) return
|
||||
|
||||
// Build list of filenames to search for
|
||||
const filenames: string[] = []
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
if (filename) filenames.push(filename)
|
||||
}
|
||||
|
||||
if (filenames.length === 0) return
|
||||
|
||||
// Use recursive directory search (searches subdirectories up to 10 levels deep)
|
||||
if (window.electronAPI.searchDirectoryForFiles) {
|
||||
const results = await window.electronAPI.searchDirectoryForFiles({ directory: dir, filenames })
|
||||
// results is { "filename.mp4" (lowercase): "C:\\full\\path\\filename.mp4" }
|
||||
|
||||
setMediaRefs(prev => prev.map(ref => {
|
||||
if (mediaStatus[ref.id] !== 'missing') return ref
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const foundPath = filename ? results[filename.toLowerCase()] : undefined
|
||||
|
||||
if (foundPath) {
|
||||
return { ...ref, path: foundPath }
|
||||
}
|
||||
return ref
|
||||
}))
|
||||
|
||||
setMediaStatus(prev => {
|
||||
const next = { ...prev }
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const foundPath = filename ? results[filename.toLowerCase()] : undefined
|
||||
if (foundPath) {
|
||||
next[ref.id] = 'ready'
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Fallback: check direct paths only (no recursive search)
|
||||
const searchPaths: string[] = []
|
||||
for (const filename of filenames) {
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
searchPaths.push(`${dir}${separator}${filename}`)
|
||||
}
|
||||
|
||||
const results = await window.electronAPI.checkFilesExist({ filePaths: searchPaths })
|
||||
|
||||
setMediaRefs(prev => prev.map(ref => {
|
||||
if (mediaStatus[ref.id] !== 'missing') return ref
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
const testPath = filename ? `${dir}${separator}${filename}` : ''
|
||||
|
||||
if (testPath && results[testPath]) {
|
||||
return { ...ref, path: testPath }
|
||||
}
|
||||
return ref
|
||||
}))
|
||||
|
||||
setMediaStatus(prev => {
|
||||
const next = { ...prev }
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
const testPath = filename ? `${dir}${separator}${filename}` : ''
|
||||
if (testPath && results[testPath]) {
|
||||
next[ref.id] = 'ready'
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error searching directory: ${err}`)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [isImporting, mediaRefs, mediaStatus])
|
||||
|
||||
// Recheck all paths
|
||||
const handleRecheckAll = useCallback(async () => {
|
||||
if (isImporting) return
|
||||
const availabilityById = await checkMediaFiles(mediaRefs)
|
||||
applyAvailabilityToStatus(mediaRefs, availabilityById)
|
||||
}, [applyAvailabilityToStatus, checkMediaFiles, isImporting, mediaRefs])
|
||||
|
||||
// Confirm import
|
||||
const handleConfirmImport = useCallback(async () => {
|
||||
if (!parsedTimeline || isImporting) return
|
||||
if (!projectId) {
|
||||
setImportError('Cannot import timeline: no active project.')
|
||||
return
|
||||
}
|
||||
|
||||
setImportError('')
|
||||
setIsImporting(true)
|
||||
|
||||
const refsToImport = mediaRefs.map(ref => ({ ...ref }))
|
||||
const copiedAssetBySource = new Map<string, {
|
||||
path: string
|
||||
bigThumbnailPath?: string
|
||||
smallThumbnailPath?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
const total = refsToImport.length
|
||||
let completed = 0
|
||||
let skipped = 0
|
||||
|
||||
setImportProgress({ total, completed: 0, skipped: 0, currentName: '' })
|
||||
|
||||
try {
|
||||
for (const ref of refsToImport) {
|
||||
const sourcePath = ref.path?.trim() || ''
|
||||
const displayName = ref.name || getFilenameFromPath(sourcePath) || 'Unnamed media'
|
||||
const currentStatus = mediaStatus[ref.id] || 'missing'
|
||||
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
|
||||
if (!sourcePath || currentStatus === 'missing') {
|
||||
skipped += 1
|
||||
completed += 1
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'skipped' }))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingCopiedAsset = copiedAssetBySource.get(sourcePath)
|
||||
if (existingCopiedAsset) {
|
||||
ref.path = existingCopiedAsset.path
|
||||
ref.bigThumbnailPath = existingCopiedAsset.bigThumbnailPath
|
||||
ref.smallThumbnailPath = existingCopiedAsset.smallThumbnailPath
|
||||
ref.width = existingCopiedAsset.width
|
||||
ref.height = existingCopiedAsset.height
|
||||
completed += 1
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
|
||||
setMediaRefs(prev => prev.map(item => (
|
||||
item.id === ref.id
|
||||
? {
|
||||
...item,
|
||||
path: existingCopiedAsset.path,
|
||||
bigThumbnailPath: existingCopiedAsset.bigThumbnailPath,
|
||||
smallThumbnailPath: existingCopiedAsset.smallThumbnailPath,
|
||||
width: existingCopiedAsset.width,
|
||||
height: existingCopiedAsset.height,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copying' }))
|
||||
let copiedAsset: {
|
||||
path: string
|
||||
bigThumbnailPath?: string
|
||||
smallThumbnailPath?: string
|
||||
width?: number
|
||||
height?: number
|
||||
} | null = null
|
||||
if (ref.type === 'video' || ref.type === 'image') {
|
||||
const copied = await addVisualAssetToProject(sourcePath, projectId, ref.type)
|
||||
if (!copied) {
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
|
||||
throw new Error(`Failed to copy media into project assets: ${displayName}`)
|
||||
}
|
||||
copiedAsset = copied
|
||||
} else {
|
||||
const copied = await addGenericAssetToProject(sourcePath, projectId)
|
||||
if (!copied?.path) {
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
|
||||
throw new Error(`Failed to copy media into project assets: ${displayName}`)
|
||||
}
|
||||
copiedAsset = { path: copied.path }
|
||||
}
|
||||
|
||||
ref.path = copiedAsset.path
|
||||
ref.bigThumbnailPath = copiedAsset.bigThumbnailPath
|
||||
ref.smallThumbnailPath = copiedAsset.smallThumbnailPath
|
||||
ref.width = copiedAsset.width
|
||||
ref.height = copiedAsset.height
|
||||
copiedAssetBySource.set(sourcePath, copiedAsset)
|
||||
completed += 1
|
||||
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
|
||||
setMediaRefs(prev => prev.map(item => (
|
||||
item.id === ref.id
|
||||
? {
|
||||
...item,
|
||||
path: copiedAsset.path,
|
||||
bigThumbnailPath: copiedAsset.bigThumbnailPath,
|
||||
smallThumbnailPath: copiedAsset.smallThumbnailPath,
|
||||
width: copiedAsset.width,
|
||||
height: copiedAsset.height,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// Update timeline refs with copied/updated paths
|
||||
const updatedTimeline: ParsedTimeline = {
|
||||
...parsedTimeline,
|
||||
mediaRefs: refsToImport,
|
||||
}
|
||||
|
||||
importParsedTimeline(updatedTimeline)
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setImportError(message)
|
||||
logger.error(`Import timeline copy failed: ${message}`)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}, [closeModal, importParsedTimeline, isImporting, mediaRefs, mediaStatus, parsedTimeline, projectId])
|
||||
|
||||
const readyCount = mediaRefs.filter(r => (mediaStatus[r.id] || 'missing') !== 'missing').length
|
||||
const totalCount = mediaRefs.length
|
||||
const allFound = readyCount === totalCount && totalCount > 0
|
||||
const interactionDisabled = isImporting
|
||||
const importPercent = importProgress.total > 0 ? (importProgress.completed / importProgress.total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[680px] max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Upload className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Import Timeline</h2>
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
Premiere Pro XML, DaVinci Resolve XML, Final Cut Pro XML/FCPXML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={interactionDisabled}
|
||||
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={`flex-1 overflow-auto p-6 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{/* Step 1: File Selection */}
|
||||
{step === 'select' && (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-zinc-700 hover:border-blue-500/50 rounded-xl p-10 text-center cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-800 group-hover:bg-blue-900/30 flex items-center justify-center mx-auto mb-4 transition-colors">
|
||||
<FileText className="h-7 w-7 text-zinc-500 group-hover:text-blue-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 font-medium mb-1">Click to select timeline file</p>
|
||||
<p className="text-xs text-zinc-600">Supports .xml (FCP 7 XML), .fcpxml</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xml,.fcpxml,.aaf"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="bg-zinc-800/50 rounded-lg p-4 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">How to export from your NLE:</h4>
|
||||
<div className="space-y-1.5 text-[11px] text-zinc-500">
|
||||
<p><span className="text-blue-400 font-medium">Premiere Pro:</span> File → Export → Final Cut Pro XML</p>
|
||||
<p><span className="text-orange-400 font-medium">DaVinci Resolve:</span> File → Export Timeline → FCP 7 XML (.xml)</p>
|
||||
<p><span className="text-blue-400 font-medium">Final Cut Pro:</span> File → Export XML</p>
|
||||
<p className="text-zinc-600 pt-1 border-t border-zinc-700/50 mt-2">
|
||||
AAF files are binary and cannot be imported directly. Please export as XML instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parsing indicator */}
|
||||
{step === 'parsing' && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-400 animate-spin mb-4" />
|
||||
<p className="text-sm text-zinc-400">Parsing timeline...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{step === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-900/20 border border-red-800/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300 mb-1">Import Error</p>
|
||||
<p className="text-xs text-red-400/80 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep('select'); setError('') }}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Try another file
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Relink media */}
|
||||
{step === 'relink' && parsedTimeline && (
|
||||
<div className="space-y-4">
|
||||
{/* Timeline info */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpandedInfo(!expandedInfo)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2 hover:text-zinc-300"
|
||||
>
|
||||
{expandedInfo ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Timeline Info
|
||||
</button>
|
||||
{expandedInfo && (
|
||||
<div className="bg-zinc-800/50 rounded-lg p-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-[11px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Name:</span>
|
||||
<span className="text-white font-medium">{parsedTimeline.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Format:</span>
|
||||
<span className="text-zinc-300">
|
||||
{parsedTimeline.format === 'fcp7xml' ? 'FCP 7 XML' : parsedTimeline.format === 'fcpxml' ? 'FCPXML' : parsedTimeline.format}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">FPS:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.fps.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Duration:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
{parsedTimeline.width && parsedTimeline.height && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Resolution:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.width}x{parsedTimeline.height}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Clips:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.clips.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Video Tracks:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.videoTrackCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Audio Tracks:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.audioTrackCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media files */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={() => setExpandedMedia(!expandedMedia)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
|
||||
>
|
||||
{expandedMedia ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Media Files ({readyCount}/{totalCount} linked)
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleSearchDirectory}
|
||||
disabled={isSearching || allFound || interactionDisabled}
|
||||
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Search a folder for missing media"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : <FolderOpen className="h-3 w-3" />}
|
||||
Search Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecheckAll}
|
||||
disabled={isChecking || interactionDisabled}
|
||||
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Recheck all file paths"
|
||||
>
|
||||
{isChecking ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
Recheck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Availability bar */}
|
||||
<div className="mb-2">
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${allFound ? 'bg-green-500' : readyCount > 0 ? 'bg-amber-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedMedia && (
|
||||
<div className="space-y-1 max-h-[300px] overflow-auto rounded-lg border border-zinc-800">
|
||||
{mediaRefs.map((ref, i) => {
|
||||
const TypeIcon = ref.type === 'video' ? FileVideo : ref.type === 'audio' ? FileAudio : Image
|
||||
const status = mediaStatus[ref.id] || 'missing'
|
||||
const isPositive = status === 'ready' || status === 'copied'
|
||||
const isMissing = status === 'missing'
|
||||
const isCopying = status === 'copying'
|
||||
const isSkipped = status === 'skipped'
|
||||
const isErrored = status === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-[11px] ${i % 2 === 0 ? 'bg-zinc-800/30' : 'bg-zinc-900/30'}`}
|
||||
>
|
||||
<TypeIcon className={`h-3.5 w-3.5 flex-shrink-0 ${
|
||||
ref.type === 'video' ? 'text-blue-400' : ref.type === 'audio' ? 'text-green-400' : 'text-blue-400'
|
||||
}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isCopying ? (
|
||||
<Loader2 className="h-3 w-3 text-blue-400 flex-shrink-0 animate-spin" />
|
||||
) : isPositive ? (
|
||||
<Check className="h-3 w-3 text-green-400 flex-shrink-0" />
|
||||
) : isSkipped ? (
|
||||
<AlertTriangle className="h-3 w-3 text-amber-400 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className={`h-3 w-3 flex-shrink-0 ${isErrored ? 'text-red-400' : 'text-red-400'}`} />
|
||||
)}
|
||||
<span className={`truncate font-medium ${
|
||||
isPositive ? 'text-zinc-300' : isSkipped ? 'text-amber-300' : 'text-red-300'
|
||||
}`}>
|
||||
{ref.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-zinc-600 truncate mt-0.5">
|
||||
{ref.path || '(no path)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isMissing && (
|
||||
<button
|
||||
onClick={() => handleRelinkFile(ref.id)}
|
||||
disabled={interactionDisabled}
|
||||
className="flex-shrink-0 px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 text-[10px] flex items-center gap-1 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Relink this file"
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
Relink
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{mediaRefs.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-zinc-600">
|
||||
No media files referenced in this timeline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step === 'relink' && (
|
||||
<>
|
||||
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center justify-between bg-zinc-900 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{importError && (
|
||||
<span className="text-red-400">{importError}</span>
|
||||
)}
|
||||
{!importError && !allFound && totalCount > 0 && (
|
||||
<span className="text-amber-400">
|
||||
{totalCount - readyCount} missing file{totalCount - readyCount !== 1 ? 's' : ''} — clips with missing media will be placeholders
|
||||
</span>
|
||||
)}
|
||||
{!importError && allFound && totalCount > 0 && (
|
||||
<span className="text-green-400">All media files found</span>
|
||||
)}
|
||||
{!importError && totalCount === 0 && (
|
||||
<span>No media references to link</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={interactionDisabled}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmImport}
|
||||
disabled={interactionDisabled}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-500 transition-colors font-medium flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Import Timeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isImporting && (
|
||||
<div className="px-6 py-3 border-t border-zinc-800 bg-zinc-900/95">
|
||||
<div className="flex items-center justify-between mb-1.5 text-[11px]">
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Copying media {importProgress.completed}/{importProgress.total}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{importProgress.skipped} skipped
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-200"
|
||||
style={{ width: `${importPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importProgress.currentName && (
|
||||
<p className="text-[10px] text-zinc-500 truncate mt-1.5">
|
||||
{importProgress.currentName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { X, Search, Keyboard, RotateCcw, Save, AlertTriangle, ChevronDown, GripVertical, Trash2 } from 'lucide-react'
|
||||
import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext'
|
||||
import {
|
||||
ACTION_REGISTRY,
|
||||
ActionId,
|
||||
KeyCombo,
|
||||
formatKeyCombo,
|
||||
findConflicts,
|
||||
ActionDefinition,
|
||||
} from '../lib/keyboard-shortcuts'
|
||||
|
||||
// ── Visual keyboard layout (US QWERTY) ──
|
||||
// Each key: { id: lowercase key id matching KeyCombo.key, label: display text, w: width units (1 = standard key) }
|
||||
interface KBKey { id: string; label: string; w: number }
|
||||
|
||||
const KB_ROWS: KBKey[][] = [
|
||||
// Row 0: Number row
|
||||
[
|
||||
{ id: '`', label: '`', w: 1 },
|
||||
{ id: '1', label: '1', w: 1 },
|
||||
{ id: '2', label: '2', w: 1 },
|
||||
{ id: '3', label: '3', w: 1 },
|
||||
{ id: '4', label: '4', w: 1 },
|
||||
{ id: '5', label: '5', w: 1 },
|
||||
{ id: '6', label: '6', w: 1 },
|
||||
{ id: '7', label: '7', w: 1 },
|
||||
{ id: '8', label: '8', w: 1 },
|
||||
{ id: '9', label: '9', w: 1 },
|
||||
{ id: '0', label: '0', w: 1 },
|
||||
{ id: '-', label: '-', w: 1 },
|
||||
{ id: '=', label: '=', w: 1 },
|
||||
{ id: 'backspace', label: 'Bksp', w: 2 },
|
||||
],
|
||||
// Row 1: QWERTY
|
||||
[
|
||||
{ id: 'tab', label: 'Tab', w: 1.5 },
|
||||
{ id: 'q', label: 'Q', w: 1 },
|
||||
{ id: 'w', label: 'W', w: 1 },
|
||||
{ id: 'e', label: 'E', w: 1 },
|
||||
{ id: 'r', label: 'R', w: 1 },
|
||||
{ id: 't', label: 'T', w: 1 },
|
||||
{ id: 'y', label: 'Y', w: 1 },
|
||||
{ id: 'u', label: 'U', w: 1 },
|
||||
{ id: 'i', label: 'I', w: 1 },
|
||||
{ id: 'o', label: 'O', w: 1 },
|
||||
{ id: 'p', label: 'P', w: 1 },
|
||||
{ id: '[', label: '[', w: 1 },
|
||||
{ id: ']', label: ']', w: 1 },
|
||||
{ id: '\\', label: '\\', w: 1.5 },
|
||||
],
|
||||
// Row 2: Home row
|
||||
[
|
||||
{ id: 'capslock', label: 'Caps', w: 1.8 },
|
||||
{ id: 'a', label: 'A', w: 1 },
|
||||
{ id: 's', label: 'S', w: 1 },
|
||||
{ id: 'd', label: 'D', w: 1 },
|
||||
{ id: 'f', label: 'F', w: 1 },
|
||||
{ id: 'g', label: 'G', w: 1 },
|
||||
{ id: 'h', label: 'H', w: 1 },
|
||||
{ id: 'j', label: 'J', w: 1 },
|
||||
{ id: 'k', label: 'K', w: 1 },
|
||||
{ id: 'l', label: 'L', w: 1 },
|
||||
{ id: ';', label: ';', w: 1 },
|
||||
{ id: "'", label: "'", w: 1 },
|
||||
{ id: 'enter', label: 'Enter', w: 2.2 },
|
||||
],
|
||||
// Row 3: Shift row
|
||||
[
|
||||
{ id: 'shift-l', label: 'Shift', w: 2.5 },
|
||||
{ id: 'z', label: 'Z', w: 1 },
|
||||
{ id: 'x', label: 'X', w: 1 },
|
||||
{ id: 'c', label: 'C', w: 1 },
|
||||
{ id: 'v', label: 'V', w: 1 },
|
||||
{ id: 'b', label: 'B', w: 1 },
|
||||
{ id: 'n', label: 'N', w: 1 },
|
||||
{ id: 'm', label: 'M', w: 1 },
|
||||
{ id: ',', label: ',', w: 1 },
|
||||
{ id: '.', label: '.', w: 1 },
|
||||
{ id: '/', label: '/', w: 1 },
|
||||
{ id: 'shift-r', label: 'Shift', w: 2.5 },
|
||||
],
|
||||
// Row 4: Bottom row
|
||||
[
|
||||
{ id: 'ctrl-l', label: 'Ctrl', w: 1.5 },
|
||||
{ id: 'alt-l', label: 'Alt', w: 1.5 },
|
||||
{ id: ' ', label: 'Space', w: 7 },
|
||||
{ id: 'alt-r', label: 'Alt', w: 1.5 },
|
||||
{ id: 'ctrl-r', label: 'Ctrl', w: 1.5 },
|
||||
],
|
||||
// Row 5: Navigation cluster
|
||||
[
|
||||
{ id: 'escape', label: 'Esc', w: 1 },
|
||||
{ id: 'delete', label: 'Del', w: 1 },
|
||||
{ id: 'home', label: 'Home', w: 1 },
|
||||
{ id: 'end', label: 'End', w: 1 },
|
||||
{ id: 'f9', label: 'F9', w: 1 },
|
||||
{ id: 'f10', label: 'F10', w: 1 },
|
||||
{ id: 'f11', label: 'F11', w: 1 },
|
||||
{ id: 'arrowleft', label: '\u2190', w: 1 },
|
||||
{ id: 'arrowup', label: '\u2191', w: 1 },
|
||||
{ id: 'arrowdown', label: '\u2193', w: 1 },
|
||||
{ id: 'arrowright', label: '\u2192', w: 1 },
|
||||
],
|
||||
]
|
||||
|
||||
// Category → color mapping
|
||||
const CATEGORY_COLORS: Record<string, { bg: string; border: string; text: string; dot: string }> = {
|
||||
Tools: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
|
||||
Transport: { bg: 'bg-emerald-600/30', border: 'border-emerald-500/60', text: 'text-emerald-300', dot: 'bg-emerald-400' },
|
||||
Editing: { bg: 'bg-amber-600/30', border: 'border-amber-500/60', text: 'text-amber-300', dot: 'bg-amber-400' },
|
||||
Marking: { bg: 'bg-rose-600/30', border: 'border-rose-500/60', text: 'text-rose-300', dot: 'bg-rose-400' },
|
||||
Timeline: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
|
||||
}
|
||||
|
||||
// Keys that are modifier indicators (not assignable targets)
|
||||
const MODIFIER_KEY_IDS = new Set(['shift-l', 'shift-r', 'ctrl-l', 'ctrl-r', 'alt-l', 'alt-r', 'capslock', 'tab'])
|
||||
|
||||
export function KeyboardShortcutsModal() {
|
||||
const {
|
||||
activeLayout,
|
||||
activePresetId,
|
||||
presets,
|
||||
switchPreset,
|
||||
updateBinding,
|
||||
resetToPreset,
|
||||
saveAsCustomPreset,
|
||||
deleteCustomPreset,
|
||||
isEditorOpen,
|
||||
setEditorOpen,
|
||||
} = useKeyboardShortcuts()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [recordingAction, setRecordingAction] = useState<ActionId | null>(null)
|
||||
const [showPresetDropdown, setShowPresetDropdown] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [savePresetName, setSavePresetName] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const recordRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Visual keyboard state
|
||||
const [kbModCtrl, setKbModCtrl] = useState(false)
|
||||
const [kbModShift, setKbModShift] = useState(false)
|
||||
const [kbModAlt, setKbModAlt] = useState(false)
|
||||
const [hoveredActionId, setHoveredActionId] = useState<ActionId | null>(null)
|
||||
const [draggedActionId, setDraggedActionId] = useState<ActionId | null>(null)
|
||||
const [dropTargetKey, setDropTargetKey] = useState<string | null>(null)
|
||||
const [showKeyboard, setShowKeyboard] = useState(true)
|
||||
|
||||
const conflicts = findConflicts(activeLayout)
|
||||
const activePreset = presets.find(p => p.id === activePresetId)
|
||||
|
||||
// ── Reverse lookup: given current modifier state, map keyId → ActionId ──
|
||||
const keyToAction = useMemo(() => {
|
||||
const map = new Map<string, { actionId: ActionId; action: ActionDefinition }>()
|
||||
for (const [actionId, combos] of Object.entries(activeLayout)) {
|
||||
if (!combos) continue
|
||||
for (const combo of combos) {
|
||||
// Check if this combo matches the current modifier filter
|
||||
const comboCtrl = !!combo.ctrl || !!combo.meta
|
||||
const comboShift = !!combo.shift
|
||||
const comboAlt = !!combo.alt
|
||||
if (comboCtrl === kbModCtrl && comboShift === kbModShift && comboAlt === kbModAlt) {
|
||||
const def = ACTION_REGISTRY.find(a => a.id === actionId)
|
||||
if (def) {
|
||||
map.set(combo.key, { actionId: actionId as ActionId, action: def })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [activeLayout, kbModCtrl, kbModShift, kbModAlt])
|
||||
|
||||
// ── Keys assigned to the hovered action (for highlighting) ──
|
||||
const hoveredKeys = useMemo(() => {
|
||||
if (!hoveredActionId) return new Set<string>()
|
||||
const combos = activeLayout[hoveredActionId] || []
|
||||
return new Set(combos.map(c => c.key))
|
||||
}, [hoveredActionId, activeLayout])
|
||||
|
||||
// Close on Escape (only when not recording)
|
||||
useEffect(() => {
|
||||
if (!isEditorOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !recordingAction) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isEditorOpen, recordingAction, setEditorOpen])
|
||||
|
||||
// Recording mode: capture the next keystroke as a new binding
|
||||
const handleRecordKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!recordingAction) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Ignore modifier-only presses
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
|
||||
|
||||
const combo: KeyCombo = {
|
||||
key: e.key.toLowerCase(),
|
||||
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
|
||||
...(e.shiftKey ? { shift: true } : {}),
|
||||
...(e.altKey ? { alt: true } : {}),
|
||||
}
|
||||
|
||||
// If Escape pressed alone, cancel recording
|
||||
if (combo.key === 'escape' && !combo.ctrl && !combo.shift && !combo.alt) {
|
||||
setRecordingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the new binding (replace existing)
|
||||
updateBinding(recordingAction, [combo])
|
||||
setRecordingAction(null)
|
||||
}, [recordingAction, updateBinding])
|
||||
|
||||
useEffect(() => {
|
||||
if (recordingAction) {
|
||||
window.addEventListener('keydown', handleRecordKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleRecordKeyDown, true)
|
||||
}
|
||||
}, [recordingAction, handleRecordKeyDown])
|
||||
|
||||
// ── Drag and drop via React state (avoids HTML5 DnD issues in Electron) ──
|
||||
// We track the dragged action in a ref so it's always current
|
||||
const draggedActionRef = useRef<ActionId | null>(null)
|
||||
|
||||
// Mouse-based drag: user mousedowns on an action, moves to a key, mouseups to drop
|
||||
const handleActionMouseDown = useCallback((e: React.MouseEvent, actionId: ActionId) => {
|
||||
// Only left-click
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
draggedActionRef.current = actionId
|
||||
setDraggedActionId(actionId)
|
||||
}, [])
|
||||
|
||||
// Global mouse handlers for drag (attached when dragging)
|
||||
useEffect(() => {
|
||||
if (!draggedActionId) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Find which keyboard key the mouse is over
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
|
||||
if (keyEl) {
|
||||
const keyId = keyEl.getAttribute('data-kb-key')
|
||||
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
|
||||
setDropTargetKey(keyId)
|
||||
} else {
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
} else {
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const actionId = draggedActionRef.current
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
|
||||
|
||||
if (actionId && keyEl) {
|
||||
const keyId = keyEl.getAttribute('data-kb-key')
|
||||
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
|
||||
// Build combo from current modifier state + dropped key
|
||||
const combo: KeyCombo = {
|
||||
key: keyId,
|
||||
...(kbModCtrl ? { ctrl: true } : {}),
|
||||
...(kbModShift ? { shift: true } : {}),
|
||||
...(kbModAlt ? { alt: true } : {}),
|
||||
}
|
||||
updateBinding(actionId, [combo])
|
||||
}
|
||||
}
|
||||
|
||||
draggedActionRef.current = null
|
||||
setDraggedActionId(null)
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [draggedActionId, kbModCtrl, kbModShift, kbModAlt, updateBinding])
|
||||
|
||||
// Click on a key to select the action assigned to it
|
||||
const handleKeyClick = useCallback((keyId: string) => {
|
||||
if (MODIFIER_KEY_IDS.has(keyId)) return
|
||||
// Don't treat mouseup from a drag as a click
|
||||
if (draggedActionRef.current) return
|
||||
const assigned = keyToAction.get(keyId)
|
||||
if (assigned) {
|
||||
setSelectedCategory(null)
|
||||
setSearchQuery(assigned.action.label)
|
||||
}
|
||||
}, [keyToAction])
|
||||
|
||||
if (!isEditorOpen) return null
|
||||
|
||||
// Filter and group actions
|
||||
const categories = ['Tools', 'Transport', 'Editing', 'Marking', 'Timeline'] as const
|
||||
const filteredActions = ACTION_REGISTRY.filter(a => {
|
||||
if (selectedCategory && a.category !== selectedCategory) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const combos = activeLayout[a.id]
|
||||
const comboStr = combos?.map(formatKeyCombo).join(' ') || ''
|
||||
return a.label.toLowerCase().includes(q) || a.category.toLowerCase().includes(q) || comboStr.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const groupedActions: Record<string, ActionDefinition[]> = {}
|
||||
for (const a of filteredActions) {
|
||||
if (!groupedActions[a.category]) groupedActions[a.category] = []
|
||||
groupedActions[a.category].push(a)
|
||||
}
|
||||
|
||||
// Unit size for keyboard keys
|
||||
const KEY_UNIT = 42 // px per 1 unit of key width
|
||||
const KEY_GAP = 2 // px gap between keys
|
||||
const KEY_H = 36 // px key height
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }}
|
||||
>
|
||||
<div className="w-[880px] max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-700/80 shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-zinc-800 bg-zinc-900/95">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Keyboard className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-sm font-semibold text-white">Keyboard Shortcuts</h2>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">Customize keybindings — drag actions onto keys or click Edit</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKeyboard(!showKeyboard)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors ${
|
||||
showKeyboard ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{showKeyboard ? 'Hide Keyboard' : 'Show Keyboard'}
|
||||
</button>
|
||||
<button onClick={() => setEditorOpen(false)} className="text-zinc-500 hover:text-white transition-colors p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Preset selector + Search + Actions */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800/80 bg-zinc-950/50">
|
||||
{/* Preset dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPresetDropdown(!showPresetDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-md border border-zinc-700/60 text-[11px] text-zinc-300 transition-colors"
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{activePreset?.name || 'Custom'}</span>
|
||||
<ChevronDown className="h-3 w-3 text-zinc-500" />
|
||||
</button>
|
||||
{showPresetDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 overflow-hidden">
|
||||
{presets.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-1 hover:bg-zinc-700 transition-colors ${
|
||||
p.id === activePresetId ? 'bg-blue-600/15' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
switchPreset(p.id)
|
||||
setShowPresetDropdown(false)
|
||||
}}
|
||||
className={`flex-1 text-left px-3 py-2 text-[11px] ${
|
||||
p.id === activePresetId ? 'text-blue-300' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">{p.description}</div>
|
||||
</button>
|
||||
{/* Delete button — only for user-created (non-built-in) presets */}
|
||||
{!p.builtIn && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete preset "${p.name}"?`)) {
|
||||
deleteCustomPreset(p.id)
|
||||
if (presets.filter(pr => pr.id !== p.id).length > 0) {
|
||||
setShowPresetDropdown(true)
|
||||
} else {
|
||||
setShowPresetDropdown(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="p-1.5 mr-1.5 rounded text-zinc-600 hover:text-red-400 hover:bg-red-600/10 transition-colors"
|
||||
title={`Delete "${p.name}"`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions or keys..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 bg-zinc-800 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
onClick={() => resetToPreset(activePresetId)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Reset all shortcuts to the selected preset"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
|
||||
{/* Save as custom */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Save current layout as a custom preset"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
Save As
|
||||
</button>
|
||||
{showSaveDialog && (
|
||||
<div className="absolute top-full right-0 mt-1 w-52 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 p-3">
|
||||
<p className="text-[10px] text-zinc-400 mb-2">Save as custom preset:</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Preset name..."
|
||||
value={savePresetName}
|
||||
onChange={(e) => setSavePresetName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && savePresetName.trim()) {
|
||||
saveAsCustomPreset(savePresetName.trim())
|
||||
setSavePresetName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 bg-zinc-900 rounded text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700 focus:border-blue-500 mb-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (savePresetName.trim()) {
|
||||
saveAsCustomPreset(savePresetName.trim())
|
||||
setSavePresetName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
}}
|
||||
disabled={!savePresetName.trim()}
|
||||
className="w-full py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-[11px] font-medium rounded transition-colors"
|
||||
>
|
||||
Save Preset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflicts.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-950/30 border-b border-amber-800/30">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-[11px] text-amber-400/90">
|
||||
{conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════ Visual Keyboard ══════════════ */}
|
||||
{showKeyboard && (
|
||||
<div className="border-b border-zinc-800/80 bg-zinc-950/60 px-4 py-3">
|
||||
{/* Modifier toggles */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<span className="text-[10px] text-zinc-500 font-medium mr-1">Modifiers:</span>
|
||||
{([
|
||||
{ label: 'Ctrl', active: kbModCtrl, toggle: () => setKbModCtrl(v => !v) },
|
||||
{ label: 'Shift', active: kbModShift, toggle: () => setKbModShift(v => !v) },
|
||||
{ label: 'Alt', active: kbModAlt, toggle: () => setKbModAlt(v => !v) },
|
||||
] as const).map(mod => (
|
||||
<button
|
||||
key={mod.label}
|
||||
onClick={mod.toggle}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold transition-all ${
|
||||
mod.active
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20'
|
||||
: 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 border border-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
{mod.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{/* Category legend */}
|
||||
<div className="flex items-center gap-3">
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded-sm ${c.dot}`} />
|
||||
<span className="text-[9px] text-zinc-500">{cat}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard rows */}
|
||||
<div className="flex flex-col items-center gap-[2px]">
|
||||
{KB_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[2px]">
|
||||
{row.map((kbKey) => {
|
||||
const isModifier = MODIFIER_KEY_IDS.has(kbKey.id)
|
||||
const assigned = keyToAction.get(kbKey.id)
|
||||
const catColors = assigned ? CATEGORY_COLORS[assigned.action.category] : null
|
||||
const isHighlighted = hoveredKeys.has(kbKey.id)
|
||||
const isDropTarget = dropTargetKey === kbKey.id
|
||||
const isDragging = !!draggedActionId
|
||||
|
||||
// Check if this key has a conflict
|
||||
const comboStr = assigned ? formatKeyCombo({
|
||||
key: kbKey.id,
|
||||
...(kbModCtrl ? { ctrl: true } : {}),
|
||||
...(kbModShift ? { shift: true } : {}),
|
||||
...(kbModAlt ? { alt: true } : {}),
|
||||
}) : null
|
||||
const hasConflict = comboStr ? conflicts.has(comboStr) : false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kbKey.id}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center rounded-md
|
||||
border text-center select-none transition-all duration-100
|
||||
${isModifier
|
||||
? 'bg-zinc-800/60 border-zinc-700/40 cursor-default'
|
||||
: isDropTarget
|
||||
? 'bg-blue-600/40 border-blue-400 ring-1 ring-blue-400 scale-105'
|
||||
: isHighlighted
|
||||
? 'bg-blue-600/30 border-blue-500 ring-1 ring-blue-400/50'
|
||||
: hasConflict
|
||||
? 'bg-amber-900/30 border-amber-600/60'
|
||||
: assigned && catColors
|
||||
? `${catColors.bg} ${catColors.border}`
|
||||
: isDragging && !isModifier
|
||||
? 'bg-zinc-800/80 border-zinc-600/80 border-dashed hover:border-blue-500/60 hover:bg-zinc-700/50'
|
||||
: 'bg-zinc-850 border-zinc-700/50 hover:border-zinc-600'
|
||||
}
|
||||
${!isModifier ? 'cursor-pointer' : ''}
|
||||
`}
|
||||
style={{
|
||||
width: kbKey.w * KEY_UNIT + (kbKey.w - 1) * KEY_GAP,
|
||||
height: KEY_H,
|
||||
}}
|
||||
data-kb-key={kbKey.id}
|
||||
onClick={() => handleKeyClick(kbKey.id)}
|
||||
title={
|
||||
assigned
|
||||
? `${assigned.action.label} (${assigned.action.category})`
|
||||
: isModifier
|
||||
? kbKey.label
|
||||
: 'Unassigned — drag an action here'
|
||||
}
|
||||
>
|
||||
{/* Key label */}
|
||||
<span className={`text-[9px] font-medium leading-none ${
|
||||
isModifier ? 'text-zinc-600' :
|
||||
assigned && catColors ? catColors.text :
|
||||
isHighlighted ? 'text-blue-300' :
|
||||
'text-zinc-500'
|
||||
}`}>
|
||||
{kbKey.label}
|
||||
</span>
|
||||
{/* Assigned action label (truncated) */}
|
||||
{assigned && !isModifier && (
|
||||
<span className={`text-[7px] leading-tight mt-0.5 max-w-full px-0.5 truncate ${
|
||||
catColors ? catColors.text : 'text-zinc-400'
|
||||
}`} style={{ opacity: 0.8 }}>
|
||||
{assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')}
|
||||
</span>
|
||||
)}
|
||||
{/* Conflict indicator */}
|
||||
{hasConflict && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{draggedActionId && (
|
||||
<div className="text-center mt-2 text-[10px] text-blue-400 animate-pulse">
|
||||
Drop on a key to assign — current modifiers: {
|
||||
[kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-zinc-800/60 bg-zinc-950/30">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors ${
|
||||
!selectedCategory ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat === selectedCategory ? null : cat)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors flex items-center gap-1.5 ${
|
||||
selectedCategory === cat ? `${c.bg} ${c.text}` : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0" ref={recordRef}>
|
||||
{categories.map(cat => {
|
||||
const actions = groupedActions[cat]
|
||||
if (!actions || actions.length === 0) return null
|
||||
const catColor = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat}>
|
||||
{/* Category header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-1.5 bg-zinc-950/95 border-b border-zinc-800/50 flex items-center gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${catColor.dot}`} />
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{cat}</span>
|
||||
</div>
|
||||
{/* Action rows */}
|
||||
{actions.map(action => {
|
||||
const combos = activeLayout[action.id] || []
|
||||
const isRecording = recordingAction === action.id
|
||||
const hasConflict = combos.some(c => {
|
||||
const key = formatKeyCombo(c)
|
||||
return conflicts.has(key)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
onMouseDown={(e) => { if (!isRecording) handleActionMouseDown(e, action.id) }}
|
||||
onMouseEnter={() => setHoveredActionId(action.id)}
|
||||
onMouseLeave={() => setHoveredActionId(null)}
|
||||
className={`flex items-center gap-3 px-4 py-2 border-b border-zinc-800/30 transition-colors select-none ${
|
||||
isRecording ? 'bg-blue-950/30' :
|
||||
draggedActionId === action.id ? 'bg-blue-600/20 ring-1 ring-blue-500/50' :
|
||||
hoveredActionId === action.id ? 'bg-zinc-800/40' :
|
||||
'hover:bg-zinc-800/30'
|
||||
} ${!isRecording ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{!isRecording && (
|
||||
<GripVertical className={`h-3 w-3 flex-shrink-0 ${
|
||||
draggedActionId === action.id ? 'text-blue-400 cursor-grabbing' : 'text-zinc-700 cursor-grab'
|
||||
}`} />
|
||||
)}
|
||||
|
||||
{/* Category dot */}
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${catColor.dot}`} />
|
||||
|
||||
{/* Action label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`text-[12px] ${hasConflict ? 'text-amber-300' : 'text-zinc-300'}`}>
|
||||
{action.label}
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className="text-[10px] text-zinc-600 ml-2">{action.description}</span>
|
||||
)}
|
||||
{hasConflict && (
|
||||
<AlertTriangle className="inline-block h-3 w-3 text-amber-400 ml-1.5 -mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current binding(s) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-600/20 border border-blue-500/50 rounded-md animate-pulse">
|
||||
<span className="text-[11px] text-blue-300">Press a key...</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRecordingAction(null) }}
|
||||
className="text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{combos.length === 0 ? (
|
||||
<span className="text-[11px] text-zinc-600 italic">Unassigned</span>
|
||||
) : (
|
||||
combos.map((combo, i) => {
|
||||
const comboKey = formatKeyCombo(combo)
|
||||
const conflicting = conflicts.has(comboKey)
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-mono font-medium ${
|
||||
conflicting
|
||||
? 'bg-amber-900/30 text-amber-300 border border-amber-700/50'
|
||||
: `${catColor.bg} ${catColor.text} border ${catColor.border}`
|
||||
}`}
|
||||
>
|
||||
{comboKey}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit / Clear buttons */}
|
||||
{!isRecording && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={() => setRecordingAction(action.id)}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-500 hover:text-blue-400 hover:bg-blue-600/10 rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{combos.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateBinding(action.id, [])}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-600 hover:text-red-400 hover:bg-red-600/10 rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-zinc-800 bg-zinc-950/80">
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditorOpen(false)}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-medium rounded-md transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { imagePathsFromDataTransfer } from '../lib/keyframe-drop'
|
||||
import type { KeyframeItem } from '../lib/multi-keyframe'
|
||||
|
||||
interface KeyframePreviewProps {
|
||||
keyframe: KeyframeItem | null
|
||||
aspectRatio: string
|
||||
onDropImages: (paths: string[]) => void
|
||||
}
|
||||
|
||||
export function KeyframePreview({
|
||||
keyframe,
|
||||
aspectRatio,
|
||||
onDropImages,
|
||||
}: KeyframePreviewProps) {
|
||||
const cssAspect = aspectRatio.includes(':')
|
||||
? aspectRatio.replace(':', ' / ')
|
||||
: aspectRatio
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const paths = imagePathsFromDataTransfer(event.dataTransfer)
|
||||
if (paths.length > 0) onDropImages(paths)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950"
|
||||
style={{ aspectRatio: cssAspect }}
|
||||
>
|
||||
{keyframe ? (
|
||||
<img
|
||||
src={pathToFileUrl(keyframe.path)}
|
||||
alt="Keyframe at the playhead"
|
||||
draggable={false}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-500">
|
||||
Add keyframes to preview your sequence
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
formatKeyframeStrength,
|
||||
nudgeKeyframeStrength,
|
||||
strengthFromPointer,
|
||||
} from '../lib/keyframe-strength'
|
||||
|
||||
interface KeyframeStrengthRailProps {
|
||||
strength: number
|
||||
label: string
|
||||
onStrengthChange: (strength: number) => void
|
||||
}
|
||||
|
||||
export function KeyframeStrengthRail({
|
||||
strength,
|
||||
label,
|
||||
onStrengthChange,
|
||||
}: KeyframeStrengthRailProps) {
|
||||
const railRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const clamped = Math.min(1, Math.max(0, strength))
|
||||
const percent = formatKeyframeStrength(strength)
|
||||
|
||||
const applyFromClientY = (clientY: number) => {
|
||||
const rect = railRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
onStrengthChange(strengthFromPointer(clientY, rect))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
role="slider"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
data-keyframe-strength
|
||||
aria-label={`Strength for keyframe at ${label}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(clamped * 100)}
|
||||
aria-valuetext={percent}
|
||||
title={`Strength ${percent}`}
|
||||
className="absolute left-0 top-0 z-20 h-11 w-3 cursor-ns-resize touch-none"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
draggingRef.current = true
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
applyFromClientY(event.clientY)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!draggingRef.current) return
|
||||
event.stopPropagation()
|
||||
applyFromClientY(event.clientY)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
draggingRef.current = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
draggingRef.current = false
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
draggingRef.current = false
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onStrengthChange(nudgeKeyframeStrength(strength, event.key === 'ArrowUp' ? 1 : -1))
|
||||
}}
|
||||
>
|
||||
<div className="relative mx-auto h-full w-0.5 rounded-full bg-zinc-700">
|
||||
<div
|
||||
className="absolute bottom-0 w-full rounded-full bg-blue-400"
|
||||
style={{ height: `${clamped * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-200 shadow"
|
||||
style={{ top: `${(1 - clamped) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { applyTimecode, nudgeKeyframe } from '../lib/keyframe-controls'
|
||||
import {
|
||||
formatKeyframeStrength,
|
||||
nudgeKeyframeStrength,
|
||||
} from '../lib/keyframe-strength'
|
||||
import {
|
||||
findNearestFreeFrameIndex,
|
||||
formatKeyframeTimecode,
|
||||
frameFromPointer,
|
||||
sameDraggedFrame,
|
||||
withDraggedFrame,
|
||||
type DraggedFrame,
|
||||
} from '../lib/keyframe-timeline'
|
||||
import type { KeyframeItem } from '../lib/multi-keyframe'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { KeyframeStrengthRail } from './KeyframeStrengthRail'
|
||||
|
||||
interface KeyframeTimelineProps {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
fps: number
|
||||
lastFrame: number
|
||||
playheadFrame: number
|
||||
onPlayheadChange: (frameIndex: number) => void
|
||||
onDragFrameChange?: (drag: DraggedFrame | null) => void
|
||||
onFrameChange: (id: string, frameIndex: number) => void
|
||||
onStrengthChange: (id: string, strength: number) => void
|
||||
onReplaceRequest: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onImagesDrop: (dataTransfer: DataTransfer, replaceId: string | null) => void
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
frameIndex: number
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
export function KeyframeTimeline({
|
||||
keyframes,
|
||||
fps,
|
||||
lastFrame,
|
||||
playheadFrame,
|
||||
onPlayheadChange,
|
||||
onDragFrameChange,
|
||||
onFrameChange,
|
||||
onStrengthChange,
|
||||
onReplaceRequest,
|
||||
onDelete,
|
||||
onImagesDrop,
|
||||
}: KeyframeTimelineProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
const onDragFrameChangeRef = useRef(onDragFrameChange)
|
||||
onDragFrameChangeRef.current = onDragFrameChange
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const [timecodeDraft, setTimecodeDraft] = useState<{
|
||||
id: string
|
||||
value: string
|
||||
} | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => onDragFrameChangeRef.current?.(null)
|
||||
}, [])
|
||||
|
||||
const updateDrag = (next: DragState | null) => {
|
||||
const prev = dragRef.current
|
||||
dragRef.current = next
|
||||
const overlay = next ? { id: next.id, frameIndex: next.frameIndex } : null
|
||||
const prevOverlay = prev ? { id: prev.id, frameIndex: prev.frameIndex } : null
|
||||
if (sameDraggedFrame(prevOverlay, overlay)) return
|
||||
setDrag(next)
|
||||
onDragFrameChangeRef.current?.(overlay)
|
||||
}
|
||||
|
||||
const displayed = withDraggedFrame(
|
||||
keyframes,
|
||||
drag ? { id: drag.id, frameIndex: drag.frameIndex } : null,
|
||||
)
|
||||
|
||||
const frameAtPointer = (clientX: number) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect()
|
||||
return rect ? frameFromPointer(clientX, rect, lastFrame) : 0
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const activeDrag = dragRef.current
|
||||
if (activeDrag && activeDrag.pointerId === event.pointerId) {
|
||||
const frameIndex = frameAtPointer(event.clientX)
|
||||
updateDrag({ ...activeDrag, frameIndex })
|
||||
onPlayheadChange(frameIndex)
|
||||
return
|
||||
}
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
onPlayheadChange(frameAtPointer(event.clientX))
|
||||
}
|
||||
}
|
||||
|
||||
const finishDrag = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const activeDrag = dragRef.current
|
||||
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return
|
||||
const otherKeyframes = keyframes.filter((keyframe) => keyframe.id !== activeDrag.id)
|
||||
const frameIndex = findNearestFreeFrameIndex(otherKeyframes, frameAtPointer(event.clientX), lastFrame)
|
||||
if (frameIndex !== null) onFrameChange(activeDrag.id, frameIndex)
|
||||
updateDrag(null)
|
||||
}
|
||||
|
||||
const positionPercent = (frameIndex: number) =>
|
||||
lastFrame > 0 ? (frameIndex / lastFrame) * 100 : 0
|
||||
|
||||
const commitTimecode = (id: string, value: string) => {
|
||||
const frameIndex = applyTimecode(keyframes, id, value, fps, lastFrame)
|
||||
if (frameIndex !== null) onFrameChange(id, frameIndex)
|
||||
setTimecodeDraft(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-16 rounded-lg border border-zinc-700 bg-zinc-950/70"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={() => updateDrag(null)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
onPlayheadChange(frameAtPointer(event.clientX))
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
onImagesDrop(event.dataTransfer, null)
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute left-2 right-2 top-1/2 h-px bg-zinc-700" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-1 w-px bg-blue-400"
|
||||
style={{ left: `${positionPercent(playheadFrame)}%` }}
|
||||
/>
|
||||
|
||||
{displayed.map((keyframe) => {
|
||||
const markerFrame = keyframe.frameIndex
|
||||
const source = keyframes.find((item) => item.id === keyframe.id) ?? keyframe
|
||||
const timecode = formatKeyframeTimecode(markerFrame, fps)
|
||||
return (
|
||||
<div
|
||||
key={keyframe.id}
|
||||
className="group absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 touch-none"
|
||||
style={{ left: `${positionPercent(markerFrame)}%` }}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onImagesDrop(event.dataTransfer, keyframe.id)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="Drag to move keyframe"
|
||||
aria-label={`Keyframe at ${timecode}`}
|
||||
className="relative block h-11 w-11 cursor-ew-resize overflow-hidden rounded-md border-2 border-blue-500 bg-zinc-900 shadow-lg"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
updateDrag({
|
||||
id: keyframe.id,
|
||||
frameIndex: source.frameIndex,
|
||||
pointerId: event.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={() => updateDrag(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onStrengthChange(
|
||||
keyframe.id,
|
||||
nudgeKeyframeStrength(source.strength, event.key === 'ArrowUp' ? 1 : -1),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const frameIndex = nudgeKeyframe(
|
||||
keyframes,
|
||||
keyframe.id,
|
||||
event.key === 'ArrowLeft' ? -1 : 1,
|
||||
lastFrame,
|
||||
)
|
||||
if (frameIndex !== null) onFrameChange(keyframe.id, frameIndex)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={pathToFileUrl(source.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-zinc-950/70 py-px text-center text-[9px] font-medium text-blue-200">
|
||||
{formatKeyframeStrength(source.strength)}
|
||||
</span>
|
||||
</button>
|
||||
<KeyframeStrengthRail
|
||||
strength={source.strength}
|
||||
label={timecode}
|
||||
onStrengthChange={(strength) => onStrengthChange(keyframe.id, strength)}
|
||||
/>
|
||||
<div className="absolute -right-3 -top-2 z-20 flex gap-0.5 opacity-0 shadow group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-replace
|
||||
title="Replace keyframe image"
|
||||
aria-label={`Replace keyframe at ${timecode}`}
|
||||
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-blue-300"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReplaceRequest(keyframe.id)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-delete
|
||||
title="Delete keyframe"
|
||||
aria-label={`Delete keyframe at ${timecode}`}
|
||||
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-red-300"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDelete(keyframe.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Timecode for keyframe at frame ${markerFrame}`}
|
||||
className="absolute left-1/2 top-full mt-0.5 h-4 w-[66px] -translate-x-1/2 rounded border border-transparent bg-zinc-950/90 px-1 text-center font-mono text-[9px] text-zinc-400 outline-none hover:border-zinc-700 focus:border-blue-500 focus:text-zinc-200"
|
||||
value={timecodeDraft?.id === keyframe.id
|
||||
? timecodeDraft.value
|
||||
: timecode}
|
||||
onFocus={(event) => {
|
||||
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
|
||||
}}
|
||||
onBlur={(event) => commitTimecode(keyframe.id, event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
if (event.key === 'Escape') {
|
||||
event.currentTarget.value = timecode
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between font-mono text-[9px] text-zinc-600">
|
||||
<span>0</span>
|
||||
<span>{lastFrame}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download, Search } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { logger } from '../lib/logger'
|
||||
import { getBackendCredentials } from '../lib/backend'
|
||||
|
||||
interface LogViewerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
// Wraps every case-insensitive occurrence of `query` in the line with a <mark>,
|
||||
// keeping all other text as-is -- marks matches instead of hiding non-matching
|
||||
// lines, so surrounding context stays visible while searching.
|
||||
function highlightMatches(line: string, query: string): React.ReactNode {
|
||||
if (!query) return line
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const parts = line.split(new RegExp(`(${escaped})`, 'gi'))
|
||||
if (parts.length === 1) return line
|
||||
// String.split with a capturing group alternates [text, match, text, match, ...] --
|
||||
// odd indices are always the captured matches.
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1
|
||||
? <mark key={i} className="bg-yellow-500/70 text-black rounded-sm px-0.5">{part}</mark>
|
||||
: <span key={i}>{part}</span>,
|
||||
)
|
||||
}
|
||||
|
||||
export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [logPath, setLogPath] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [tokenCopied, setTokenCopied] = useState(false)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
// Autoscroll doesn't make sense while browsing search matches -- remember
|
||||
// the user's prior preference so clearing the search restores it exactly.
|
||||
const savedAutoScrollRef = useRef<boolean | null>(null)
|
||||
|
||||
// Debounce the raw input so typing doesn't fire an IPC round-trip (a full
|
||||
// untruncated log read while searching) on every keystroke.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearchQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!window.electronAPI?.getLogs) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.getLogs({ query: searchQuery || undefined })
|
||||
setLogs(result.lines || [])
|
||||
setLogPath(result.logPath || '')
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch logs: ${error}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
fetchLogs()
|
||||
// Live-tail only when NOT searching. A search reads the whole session file and
|
||||
// ships every line over IPC (then re-highlights each), so polling that every 2s is a
|
||||
// large CPU/IPC cost for no gain -- results are a point-in-time view. Fetch once per
|
||||
// query change instead (this effect re-runs on searchQuery), and skip the interval.
|
||||
if (searchQuery) return
|
||||
const interval = setInterval(fetchLogs, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOpen, searchQuery])
|
||||
|
||||
const matchCount = searchQuery
|
||||
? logs.filter(l => l.toLowerCase().includes(searchQuery.toLowerCase())).length
|
||||
: 0
|
||||
|
||||
useEffect(() => {
|
||||
const isSearching = Boolean(searchQuery)
|
||||
if (isSearching && savedAutoScrollRef.current === null) {
|
||||
savedAutoScrollRef.current = autoScroll
|
||||
setAutoScroll(false)
|
||||
} else if (!isSearching && savedAutoScrollRef.current !== null) {
|
||||
setAutoScroll(savedAutoScrollRef.current)
|
||||
savedAutoScrollRef.current = null
|
||||
}
|
||||
// autoScroll intentionally excluded -- this only reacts to searchQuery
|
||||
// transitions, not to the manual toggle (which is disabled while searching anyway).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs, autoScroll])
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
if (window.electronAPI?.openLogFolder) {
|
||||
await window.electronAPI.openLogFolder()
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the backend Bearer token so local QA/perf scripts can authenticate
|
||||
// against the Electron-spawned backend (its token is random per launch).
|
||||
// Surfaced via a semi-hidden control in the footer (see below).
|
||||
const handleCopyToken = async () => {
|
||||
try {
|
||||
const { token } = await getBackendCredentials()
|
||||
if (!token) {
|
||||
logger.error('No backend token available to copy')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(token)
|
||||
setTokenCopied(true)
|
||||
setTimeout(() => setTokenCopied(false), 1200)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to copy backend token: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = logs.join('\n')
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = logPath ? logPath.split(/[/\\]/).pop() || 'session.log' : 'session.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const panel = (
|
||||
<div className={`bg-zinc-900 rounded-lg border border-zinc-700 w-full ${embedded ? 'h-full' : 'max-w-5xl h-[80vh]'} flex flex-col`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Logs</h2>
|
||||
<span className="text-xs text-zinc-500 font-mono truncate max-w-[300px]" title={logPath}>
|
||||
{logPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={logs.length === 0}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Download logs"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenFolder}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Open log folder"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
disabled={Boolean(searchQuery)}
|
||||
className={`${autoScroll ? 'text-blue-400' : 'text-zinc-400'} hover:text-white disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
title={searchQuery ? 'Auto-scroll disabled while searching' : autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
|
||||
>
|
||||
{autoScroll ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800">
|
||||
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search logs (case-insensitive)..."
|
||||
className="flex-1 bg-transparent text-xs text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0">
|
||||
{matchCount} matching line{matchCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs bg-black"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-zinc-500 text-center py-8">
|
||||
No logs yet...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{logs.map((line, index) => {
|
||||
// Color code log levels
|
||||
let lineClass = 'text-zinc-300'
|
||||
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
|
||||
lineClass = 'text-red-400'
|
||||
} else if (line.includes(' - WARNING - ')) {
|
||||
lineClass = 'text-yellow-400'
|
||||
} else if (line.includes(' - INFO - ')) {
|
||||
lineClass = 'text-blue-300'
|
||||
} else if (line.includes(' - DEBUG - ')) {
|
||||
lineClass = 'text-zinc-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={`${lineClass} whitespace-pre-wrap break-all`}>
|
||||
{highlightMatches(line, searchQuery)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-zinc-700 text-xs text-zinc-500">
|
||||
<span>{logs.length} lines {searchQuery ? '(full session)' : '(last 200)'}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Semi-hidden: copies the backend Bearer token for local QA/perf scripts
|
||||
(the Electron-spawned backend's token is random per launch). Muted until
|
||||
hover so it stays out of the way. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyToken()}
|
||||
className="font-mono tracking-widest text-zinc-700 hover:text-zinc-300 transition-colors"
|
||||
title="Copy backend auth token"
|
||||
aria-label="Copy backend auth token"
|
||||
>
|
||||
{tokenCopied ? 'copied!' : '•••••'}
|
||||
</button>
|
||||
<span>Auto-refreshing every 2s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return panel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
{panel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useId, useRef } from 'react'
|
||||
import { Info, ExternalLink } from 'lucide-react'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
|
||||
type InstructionSection = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>[number]
|
||||
|
||||
const COMMUNITY_DISCLAIMER =
|
||||
'This LoRA was created by a community member, not LTX. LTX does not endorse or take responsibility for community LoRAs. LTX has no control over the LoRA and any claims of any kind should be directed to the creator of the LoRA.'
|
||||
|
||||
// Info icon → popover with a LoRA/IC-LoRA's instruction sections + a link to its HF page
|
||||
// (derived from repoId, no stored URL). Native popover API: top-layer (escapes the
|
||||
// modal's overflow clipping and stacks above the <dialog>), with ESC + click-outside
|
||||
// dismiss for free. The popover/popovertarget attrs aren't in @types/react 18.3's JSX
|
||||
// yet, so they're declared via cast objects below — but statically, so the element is
|
||||
// never rendered without `popover` (setting it post-mount let it flash inline in the row).
|
||||
export function LoraInfoPopover({
|
||||
sections,
|
||||
name,
|
||||
repoId,
|
||||
isCommunity = true,
|
||||
maxHeight = '70vh',
|
||||
}: {
|
||||
sections: InstructionSection[]
|
||||
name: string
|
||||
repoId?: string
|
||||
// Fail closed: missing affiliation → show disclaimer.
|
||||
isCommunity?: boolean
|
||||
// Caller-tunable cap so the popover scrolls instead of overflowing its container
|
||||
// (e.g. the library modal needs a lower value than it does elsewhere).
|
||||
maxHeight?: string
|
||||
}) {
|
||||
const id = useId()
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const popRef = useRef<HTMLDivElement>(null)
|
||||
const showCommunityDisclaimer = isCommunity
|
||||
|
||||
if (sections.length === 0 && !repoId && !showCommunityDisclaimer) return null
|
||||
|
||||
// Native Popover API attrs missing from @types/react 18.3's JSX; cast so they render.
|
||||
const popoverAttrs = { popover: 'auto' } as unknown as React.HTMLAttributes<HTMLDivElement>
|
||||
const triggerAttrs = { popovertarget: id } as unknown as React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
|
||||
// The popover is viewport-fixed in the top layer; anchor it above the trigger on open.
|
||||
const anchor = () => {
|
||||
const btn = btnRef.current, pop = popRef.current
|
||||
if (!btn || !pop) return
|
||||
const r = btn.getBoundingClientRect()
|
||||
pop.style.margin = '0'
|
||||
pop.style.right = 'auto'
|
||||
pop.style.top = 'auto'
|
||||
pop.style.left = `${Math.max(8, r.left)}px`
|
||||
pop.style.bottom = `${window.innerHeight - r.top + 6}px`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
{...triggerAttrs}
|
||||
onClick={anchor}
|
||||
aria-label={`${name} instructions`}
|
||||
className="rounded p-0.5 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
ref={popRef}
|
||||
id={id}
|
||||
{...popoverAttrs}
|
||||
aria-label={`${name} instructions`}
|
||||
style={{ maxHeight }}
|
||||
className="w-72 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-3 text-left text-white shadow-xl"
|
||||
>
|
||||
<div className="mb-1.5 text-xs font-semibold text-white">{name}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sections.map((s, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{s.title}</div>
|
||||
{Array.isArray(s.body) ? (
|
||||
<ul className="mt-0.5 list-disc pl-4 text-[11px] text-zinc-300">
|
||||
{s.body.map((b, j) => <li key={j}>{b}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-0.5 text-[11px] text-zinc-300">{s.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showCommunityDisclaimer && (
|
||||
<p className="mt-2.5 border-t border-zinc-800 pt-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{COMMUNITY_DISCLAIMER}
|
||||
</p>
|
||||
)}
|
||||
{repoId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void window.electronAPI.openHuggingFaceRepo({ repoId }) }}
|
||||
className="mt-2.5 inline-flex items-center gap-1 text-[11px] font-medium text-indigo-400 transition-colors hover:text-indigo-300"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> View on HuggingFace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { LibraryModal } from './library/LibraryModal'
|
||||
import { LibraryItemCard } from './library/LibraryItemCard'
|
||||
import { LoraInfoPopover } from './LoraInfoPopover'
|
||||
import { useHfAuth } from '../hooks/use-hf-auth'
|
||||
import type { LibraryEntry } from '../lib/lora-library'
|
||||
|
||||
export type LibraryKind = 'lora' | 'ic-lora'
|
||||
|
||||
interface LoraLibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
kind: LibraryKind
|
||||
items: LibraryEntry[]
|
||||
selectedId: string | null
|
||||
// Which variant of selectedId is actually active — lets the card badge the exact
|
||||
// installed checkpoint as "Selected" rather than the whole catalog entry.
|
||||
selectedVariantId?: string | null
|
||||
downloadingKey: string | null
|
||||
progress: number
|
||||
downloadError: { key: string; message: string } | null
|
||||
// Transient message (e.g. "still syncing installed files") shown when onSelect returns false.
|
||||
syncError?: string | null
|
||||
onDownload: (id: string, variantId?: string) => void
|
||||
// Returns whether the entry was actually selected — the modal only closes on success.
|
||||
onSelect: (entry: LibraryEntry, variantId?: string) => boolean
|
||||
}
|
||||
|
||||
// Generic catalog library modal — works for both plain LoRAs and IC-LoRAs. The consumer
|
||||
// maps its API/on-disk types into LibraryEntry[]; LibraryModal/LibraryItemCard stay
|
||||
// domain-agnostic.
|
||||
export function LoraLibraryModal({
|
||||
open, onClose, kind, items, selectedId, selectedVariantId, downloadingKey, progress, downloadError,
|
||||
syncError, onDownload, onSelect,
|
||||
}: LoraLibraryModalProps) {
|
||||
// A catalog entry with no download variants has nothing to show a card for.
|
||||
const visibleItems = items.filter(e => (e.variants?.length ?? 0) > 0)
|
||||
// HF "Connect" CTA: shown only when a gated item is visible and we're not signed in.
|
||||
const anyGated = visibleItems.some(e => e.requiresHfLogin)
|
||||
const { hfAuthStatus } = useHfAuth(open && anyGated)
|
||||
const showConnectCta = anyGated && hfAuthStatus !== 'authenticated'
|
||||
const handleConnect = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }))
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={kind === 'ic-lora' ? 'IC-LoRAs' : 'LoRAs'}
|
||||
headerSlot={showConnectCta ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
Connect HuggingFace
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
{syncError && (
|
||||
<div className="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center text-xs text-zinc-500">
|
||||
{kind === 'ic-lora' ? 'No IC-LoRAs available.' : 'No LoRAs found. Downloaded and installed LoRAs will appear here.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
{visibleItems.map(e => {
|
||||
const cardError = !downloadError ? null
|
||||
: (e.requiresHfLogin && hfAuthStatus !== 'authenticated')
|
||||
? { key: downloadError.key, message: 'Sign in to HuggingFace (top right), then retry.', gated: false }
|
||||
: { ...downloadError, gated: Boolean(e.requiresHfLogin) }
|
||||
return (
|
||||
<LibraryItemCard
|
||||
key={e.id}
|
||||
selected={selectedId === e.id}
|
||||
selectedVariantId={selectedId === e.id ? selectedVariantId : undefined}
|
||||
downloadingKey={downloadingKey}
|
||||
progress={progress}
|
||||
downloadError={cardError}
|
||||
item={{
|
||||
id: e.id,
|
||||
title: e.name,
|
||||
description: e.description || undefined,
|
||||
sizeBytes: e.sizeBytes,
|
||||
downloaded: e.downloaded,
|
||||
thumbnailUrl: e.thumbnailUrl,
|
||||
demoVideoUrl: e.demoVideoUrl,
|
||||
author: e.author,
|
||||
license: e.license,
|
||||
requiresHfLogin: e.requiresHfLogin,
|
||||
variants: e.variants?.map(v => ({
|
||||
id: v.id,
|
||||
label: v.label,
|
||||
sizeBytes: v.sizeBytes,
|
||||
downloaded: e.downloadedVariantIds?.includes(v.id),
|
||||
})),
|
||||
defaultVariantId: e.defaultVariantId,
|
||||
}}
|
||||
infoSlot={(
|
||||
<LoraInfoPopover
|
||||
sections={e.instructions ?? []}
|
||||
name={e.name}
|
||||
repoId={e.repoId}
|
||||
isCommunity={e.author?.affiliation !== 'ltx'}
|
||||
maxHeight="45vh"
|
||||
/>
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
onRetry={onDownload}
|
||||
onRequestAccess={() => { if (e.repoId) void window.electronAPI.openHuggingFaceRepo({ repoId: e.repoId }) }}
|
||||
onUse={(_id, variantId) => { if (onSelect(e, variantId)) onClose() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</LibraryModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import { ExternalLink, KeyRound } from 'lucide-react'
|
||||
|
||||
interface LtxApiKeyInputProps {
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
id?: string
|
||||
stopPropagation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const LtxApiKeyInput = forwardRef<HTMLInputElement, LtxApiKeyInputProps>(
|
||||
({ value, onChange, placeholder = 'Paste your API key', id, stopPropagation, className }, ref) => {
|
||||
return (
|
||||
<div className={`relative ${className ?? ''}`}>
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 py-2.5 pl-9 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
LtxApiKeyInput.displayName = 'LtxApiKeyInput'
|
||||
|
||||
interface ApiKeyHelperRowProps {
|
||||
stopPropagation?: boolean
|
||||
label?: string
|
||||
onOpenKey?: () => void
|
||||
}
|
||||
|
||||
export function ApiKeyHelperRow({ stopPropagation, label = 'Get API key', onOpenKey }: ApiKeyHelperRowProps) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">Your key stays in your local app settings.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation()
|
||||
onOpenKey?.()
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LtxApiKeyHelperRowProps {
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
|
||||
export function LtxApiKeyHelperRow({ stopPropagation }: LtxApiKeyHelperRowProps) {
|
||||
return (
|
||||
<ApiKeyHelperRow
|
||||
stopPropagation={stopPropagation}
|
||||
label="Get API key"
|
||||
onOpenKey={() => window.electronAPI.openLtxApiKeyPage()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface LtxLogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LtxLogo({ className = "h-6" }: LtxLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 75 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="currentColor"/>
|
||||
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="currentColor"/>
|
||||
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.ltx-upgrade-backdrop {
|
||||
animation: ltx-upgrade-backdrop-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.ltx-upgrade-card {
|
||||
animation: ltx-upgrade-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ltx-upgrade-reveal {
|
||||
animation: ltx-upgrade-reveal 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ltx-upgrade-indeterminate {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ltx-upgrade-indeterminate::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 40%;
|
||||
background: linear-gradient(90deg, transparent, rgba(191, 219, 254, 0.95), transparent);
|
||||
animation: ltx-upgrade-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ltx-upgrade-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ltx-upgrade-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ltx-upgrade-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ltx-upgrade-indeterminate {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Download, Loader2, Sparkles, X } from 'lucide-react'
|
||||
import { useHfAuth } from '../hooks/use-hf-auth'
|
||||
import { useHfModelAccess } from '../hooks/use-hf-model-access'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { HfModelAccessGate } from './HfModelAccessGate'
|
||||
import { Button } from './ui/button'
|
||||
import './LtxUpgradePrompt.css'
|
||||
|
||||
type UpgradeRecommendation = Extract<ApiSuccessOf<'getLtxRecommendation'>, { status: 'upgrade' }>
|
||||
type ModelCheckpointID = NonNullable<
|
||||
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
|
||||
>[number]
|
||||
|
||||
interface LtxUpgradePromptProps {
|
||||
recommendation: UpgradeRecommendation
|
||||
onClose: () => void
|
||||
// Dismiss permanently for this model id (persisted), so the prompt doesn't return for it.
|
||||
onDontShowAgain: () => void
|
||||
onComplete: () => Promise<void> | void
|
||||
}
|
||||
|
||||
type UpgradePhase = 'idle' | 'starting' | 'downloading' | 'finishing'
|
||||
|
||||
function formatCheckpointId(cpId: string): string {
|
||||
return cpId.replace(/-/g, ' ')
|
||||
}
|
||||
|
||||
export function LtxUpgradePrompt({
|
||||
recommendation,
|
||||
onClose,
|
||||
onDontShowAgain,
|
||||
onComplete,
|
||||
}: LtxUpgradePromptProps) {
|
||||
const [wantsUpgrade, setWantsUpgrade] = useState(false)
|
||||
const [phase, setPhase] = useState<UpgradePhase>('idle')
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<ApiSuccessOf<'getModelDownloadProgress'> | null>(
|
||||
null,
|
||||
)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
// Default off when deleting the old bundle would also wipe built-in Union Control IC-LoRA
|
||||
// (2.3 → 2.5). Otherwise default on to reclaim the tens of GB the old transformer uses.
|
||||
const [deleteOld, setDeleteOld] = useState(!recommendation.loses_built_in_control)
|
||||
|
||||
const cpsToDownload = useMemo(
|
||||
() => recommendation.cps_to_download as ModelCheckpointID[],
|
||||
[recommendation.cps_to_download],
|
||||
)
|
||||
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
|
||||
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
|
||||
cpsToDownload,
|
||||
hfAuthStatus,
|
||||
)
|
||||
|
||||
const hasOldToDelete = recommendation.cps_to_delete.length > 0
|
||||
const canClose = phase === 'idle'
|
||||
const canStartUpgrade = wantsUpgrade && phase === 'idle' && allAuthorized && !checkingAccess
|
||||
|
||||
const runningProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null
|
||||
const totalProgress = runningProgress?.total_progress ?? (phase === 'finishing' ? 100 : 0)
|
||||
const completedCount = runningProgress?.completed_files.length ?? 0
|
||||
const totalCount = runningProgress?.all_files.length ?? recommendation.cps_to_download.length
|
||||
|
||||
useEffect(() => {
|
||||
if (!canClose) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [canClose, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'downloading' || !downloadSessionId) return
|
||||
|
||||
let cancelled = false
|
||||
const pollProgress = async () => {
|
||||
const progressResult = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (!progressResult.ok) {
|
||||
logger.warn(`Failed polling LTX upgrade progress: ${progressResult.error.message}`)
|
||||
return
|
||||
}
|
||||
if (cancelled) return
|
||||
|
||||
const progress = progressResult.data
|
||||
setDownloadProgress(progress)
|
||||
|
||||
if (progress.status === 'error') {
|
||||
setPhase('idle')
|
||||
setErrorMessage(progress.error || 'Upgrade download failed.')
|
||||
return
|
||||
}
|
||||
|
||||
if (progress.status === 'complete') {
|
||||
setPhase('finishing')
|
||||
if (deleteOld && recommendation.cps_to_delete.length > 0) {
|
||||
const deleteResult = await ApiClient.deleteModels({ cp_ids: recommendation.cps_to_delete })
|
||||
if (!deleteResult.ok) {
|
||||
logger.error(`Failed finalizing LTX upgrade: ${deleteResult.error.message}`)
|
||||
if (cancelled) return
|
||||
setPhase('idle')
|
||||
setErrorMessage(deleteResult.error.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onComplete()
|
||||
if (cancelled) return
|
||||
// Reset to idle rather than waiting for the parent's refresh to flip away from 'upgrade'
|
||||
// — if the active model isn't flipped yet we'd otherwise be stuck on "Finishing up...".
|
||||
setPhase('idle')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
logger.error(`Failed finalizing LTX upgrade: ${e}`)
|
||||
if (cancelled) return
|
||||
setPhase('idle')
|
||||
setErrorMessage(e instanceof Error ? e.message : 'Upgrade downloaded, but cleanup failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pollProgress()
|
||||
const interval = setInterval(() => {
|
||||
void pollProgress()
|
||||
}, 700)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [deleteOld, downloadSessionId, onClose, onComplete, phase, recommendation.cps_to_delete])
|
||||
|
||||
const handleStartUpgrade = useCallback(async () => {
|
||||
if (!canStartUpgrade) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setDownloadProgress(null)
|
||||
setPhase('starting')
|
||||
|
||||
const result = await ApiClient.startModelDownload({
|
||||
type: 'upgrade',
|
||||
cp_ids: recommendation.cps_to_download,
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to start LTX upgrade download: ${result.error.message}`)
|
||||
setPhase('idle')
|
||||
setErrorMessage(result.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const response = result.data
|
||||
if (response.status !== 'started') {
|
||||
setPhase('idle')
|
||||
setErrorMessage('Unexpected response while starting the upgrade.')
|
||||
return
|
||||
}
|
||||
setDownloadSessionId(response.sessionId)
|
||||
setPhase('downloading')
|
||||
}, [canStartUpgrade, recommendation.cps_to_download])
|
||||
|
||||
return (
|
||||
<div className="ltx-upgrade-backdrop fixed inset-0 z-[55] flex items-center justify-center bg-black/72 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onClick={() => {
|
||||
if (canClose) onClose()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="ltx-upgrade-card relative w-full max-w-[640px] overflow-hidden rounded-[28px] border border-blue-500/20 bg-[#04070d] shadow-[0_24px_120px_rgba(0,0,0,0.72)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.68),rgba(2,6,23,0.16))]" />
|
||||
<div className="relative px-6 pb-6 pt-6 sm:px-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-0.5 flex h-12 w-12 items-center justify-center rounded-2xl border border-blue-400/20 bg-blue-500/12">
|
||||
<Sparkles className="h-5 w-5 text-blue-200" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="inline-flex items-center rounded-full border border-blue-400/20 bg-blue-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-200/85">
|
||||
Optional Upgrade
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-[30px]">
|
||||
LTX Model Upgrade Detected!
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-blue-100/78">
|
||||
Upgrade target: <span className="font-medium text-blue-50">{recommendation.ltx_model_id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDontShowAgain}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
aria-label="Close LTX upgrade prompt"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-blue-400/16 bg-[linear-gradient(145deg,rgba(10,14,22,0.98),rgba(5,8,14,0.96))] p-5 sm:p-6">
|
||||
{recommendation.upgrade_message ? (
|
||||
<>
|
||||
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">What's new in this version</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.upgrade_message.split('\n').map((line) => line.trim()).filter(Boolean).map((line, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm leading-relaxed text-blue-100/82">
|
||||
<span className="mt-0.5 text-blue-300/70">•</span>
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">
|
||||
A better LTX checkpoint is ready for this install.
|
||||
</p>
|
||||
)}
|
||||
{hasOldToDelete && (
|
||||
<p className="mt-3 max-w-[44ch] text-sm leading-relaxed text-blue-100/72">
|
||||
{deleteOld
|
||||
? recommendation.loses_built_in_control
|
||||
? 'Your previous checkpoint and its built-in depth/canny/pose control models will be removed from disk.'
|
||||
: 'Your previous checkpoint will be removed from disk once the download completes.'
|
||||
: 'Your previous checkpoint will be kept — switch between versions anytime in Settings → Models.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wantsUpgrade}
|
||||
onChange={(event) => setWantsUpgrade(event.target.checked)}
|
||||
disabled={!canClose}
|
||||
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="font-medium">I want this!</span>
|
||||
</label>
|
||||
|
||||
{wantsUpgrade && hasOldToDelete && (
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteOld}
|
||||
onChange={(event) => setDeleteOld(event.target.checked)}
|
||||
disabled={!canClose}
|
||||
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{recommendation.loses_built_in_control
|
||||
? 'Delete the previous checkpoint (also removes built-in control models)'
|
||||
: 'Delete the previous checkpoint to free up disk space'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wantsUpgrade && (
|
||||
<div className="mt-5 space-y-4">
|
||||
{!allAuthorized && (
|
||||
<div className="ltx-upgrade-reveal rounded-2xl border border-amber-400/16 bg-[#060b14] p-5">
|
||||
<HfModelAccessGate
|
||||
accessMap={accessMap}
|
||||
allAuthorized={allAuthorized}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={() => {
|
||||
void startHuggingFaceLogin()
|
||||
}}
|
||||
checkError={checkError}
|
||||
onRetryCheck={recheckAccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canStartUpgrade && (
|
||||
<div className="ltx-upgrade-reveal rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Ready when you are</p>
|
||||
<p className="mt-1 text-sm text-blue-100/72">
|
||||
This will download {recommendation.cps_to_download.length} checkpoint
|
||||
{recommendation.cps_to_download.length === 1 ? '' : 's'}
|
||||
{hasOldToDelete && deleteOld ? ' and remove the old one.' : '.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleStartUpgrade()
|
||||
}}
|
||||
className="bg-blue-600 text-white hover:bg-blue-500"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Upgrade now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase !== 'idle' && (
|
||||
<div className="mt-5 rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{phase === 'starting' ? 'Preparing your upgrade...' : phase === 'downloading' ? 'Downloading update...' : 'Finishing up...'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-blue-100/70">
|
||||
{phase === 'finishing'
|
||||
? (deleteOld && hasOldToDelete ? 'Cleaning up the previous checkpoint files.' : 'Finalizing the upgrade.')
|
||||
: 'Keep this window open while the new checkpoint is downloaded.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-blue-400/14 bg-blue-500/10 px-3 py-1 text-xs text-blue-100/82">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Working
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-blue-100/72">
|
||||
<span>
|
||||
{phase === 'starting'
|
||||
? 'Waiting for the download session to start'
|
||||
: `Checkpoint progress ${Math.min(completedCount + (runningProgress ? 1 : 0), totalCount)} / ${totalCount}`}
|
||||
</span>
|
||||
<span>{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-950/60">
|
||||
{phase === 'starting' ? (
|
||||
<div className="ltx-upgrade-indeterminate h-full bg-blue-500/55" />
|
||||
) : (
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#60a5fa,#2563eb)] transition-all duration-300"
|
||||
style={{ width: `${Math.max(totalProgress, 4)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runningProgress?.current_downloading_file && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-blue-100/70">
|
||||
<span className="truncate">Current file</span>
|
||||
<span>{Math.round(runningProgress.current_file_progress)}%</span>
|
||||
</div>
|
||||
<div className="truncate text-sm text-blue-50/85">
|
||||
{formatCheckpointId(runningProgress.current_downloading_file)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mt-5 flex items-start gap-3 rounded-2xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
separator?: boolean // renders a divider line
|
||||
submenu?: MenuItem[]
|
||||
}
|
||||
|
||||
export interface MenuDefinition {
|
||||
id: string
|
||||
label: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
interface MenuBarProps {
|
||||
menus: MenuDefinition[]
|
||||
rightContent?: React.ReactNode
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function MenuBar({ menus, rightContent }: MenuBarProps) {
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||
const [hoverMenuId, setHoverMenuId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [highlightedResult, setHighlightedResult] = useState(0)
|
||||
const menuBarRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The active open menu (follow hover once a menu is open)
|
||||
const activeMenuId = openMenuId ? (hoverMenuId || openMenuId) : null
|
||||
|
||||
// Close menus on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuBarRef.current && !menuBarRef.current.contains(e.target as Node)) {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
}
|
||||
if (openMenuId) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [openMenuId])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (!query) return []
|
||||
const results: { menuLabel: string; item: MenuItem }[] = []
|
||||
for (const menu of menus) {
|
||||
for (const item of menu.items) {
|
||||
if (item.separator) continue
|
||||
if (item.label.toLowerCase().includes(query)) {
|
||||
results.push({ menuLabel: menu.label, item })
|
||||
}
|
||||
if (item.submenu) {
|
||||
for (const sub of item.submenu) {
|
||||
if (sub.separator) continue
|
||||
if (sub.label.toLowerCase().includes(query)) {
|
||||
results.push({ menuLabel: `${menu.label} > ${item.label}`, item: sub })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}, [menus, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedResult(prev => {
|
||||
if (searchResults.length === 0) return 0
|
||||
return Math.min(prev, searchResults.length - 1)
|
||||
})
|
||||
}, [searchResults.length])
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
if (item.disabled || !item.action) return
|
||||
item.action()
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
|
||||
const handleSearchResultClick = (item: MenuItem) => {
|
||||
if (item.disabled || !item.action) return
|
||||
item.action()
|
||||
setSearchQuery('')
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
|
||||
// Handle search keyboard navigation
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlightedResult(prev => Math.min(prev + 1, searchResults.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlightedResult(prev => Math.max(prev - 1, 0))
|
||||
} else if (e.key === 'Enter' && searchResults[highlightedResult]) {
|
||||
e.preventDefault()
|
||||
handleSearchResultClick(searchResults[highlightedResult].item)
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenuItem = (item: MenuItem, index: number) => {
|
||||
if (item.separator) {
|
||||
return <div key={`sep-${index}`} className="h-px bg-zinc-700 my-1 mx-2" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-left text-[13px] transition-colors ${
|
||||
item.disabled
|
||||
? 'text-zinc-600 cursor-not-allowed'
|
||||
: 'text-zinc-200 hover:bg-blue-600 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className={`ml-8 text-[11px] ${item.disabled ? 'text-zinc-700' : 'text-zinc-500'}`}>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuBarRef} className="flex items-center bg-zinc-900 border-b border-zinc-800 select-none relative z-[60]">
|
||||
<div className="flex items-center flex-1">
|
||||
{menus.map(menu => {
|
||||
const isActive = activeMenuId === menu.id
|
||||
const isHelpMenu = menu.id === 'help'
|
||||
|
||||
return (
|
||||
<div key={menu.id} className="relative">
|
||||
<button
|
||||
onMouseDown={() => {
|
||||
if (openMenuId === menu.id) {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
} else {
|
||||
setOpenMenuId(menu.id)
|
||||
setHoverMenuId(null)
|
||||
if (isHelpMenu) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (openMenuId) setHoverMenuId(menu.id)
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{menu.label}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isActive && (
|
||||
<div className="absolute top-full left-0 min-w-[240px] bg-zinc-900 border border-zinc-700 rounded-b-lg shadow-xl shadow-black/50 py-1 z-[60]">
|
||||
{/* Help menu has search */}
|
||||
{isHelpMenu && (
|
||||
<div className="px-2 py-1.5 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-2 bg-zinc-800 rounded px-2 py-1">
|
||||
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search menus..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 bg-transparent text-[13px] text-white placeholder-zinc-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{/* Search results */}
|
||||
{searchQuery && (
|
||||
<div className="mt-1 max-h-48 overflow-y-auto">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="text-[12px] text-zinc-500 px-2 py-2 text-center">No results</div>
|
||||
) : (
|
||||
searchResults.map((result, i) => (
|
||||
<button
|
||||
key={`${result.item.id}-${i}`}
|
||||
onClick={() => handleSearchResultClick(result.item)}
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 text-left text-[12px] rounded transition-colors ${
|
||||
i === highlightedResult
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span>{result.item.label}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-2">{result.menuLabel}</span>
|
||||
</div>
|
||||
{result.item.shortcut && (
|
||||
<span className="text-[10px] text-zinc-500">{result.item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular menu items */}
|
||||
{menu.items.map((item, i) => renderMenuItem(item, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{rightContent && (
|
||||
<div className="flex items-center mr-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, Plus } from 'lucide-react'
|
||||
import { imagePathsFromDataTransfer, imagePathsFromFiles } from '../lib/keyframe-drop'
|
||||
import { lastFrameFromDuration, type DraggedFrame } from '../lib/keyframe-timeline'
|
||||
import { applyKeyframeImagePaths, type KeyframeItem } from '../lib/multi-keyframe'
|
||||
import { KeyframeTimeline } from './KeyframeTimeline'
|
||||
|
||||
interface MultiKeyframePanelProps {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
duration: number | null
|
||||
fps: number
|
||||
maxCount: number
|
||||
playheadFrame: number
|
||||
onPlayheadChange: (frameIndex: number) => void
|
||||
onChange: (keyframes: KeyframeItem[]) => void
|
||||
onDragFrameChange?: (drag: DraggedFrame | null) => void
|
||||
}
|
||||
|
||||
export function MultiKeyframePanel({
|
||||
keyframes,
|
||||
duration,
|
||||
fps,
|
||||
maxCount,
|
||||
playheadFrame,
|
||||
onPlayheadChange,
|
||||
onChange,
|
||||
onDragFrameChange,
|
||||
}: MultiKeyframePanelProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const replaceIdRef = useRef<string | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const lastFrame = duration == null ? null : lastFrameFromDuration(duration, fps)
|
||||
const addEnabled = lastFrame !== null && keyframes.length < maxCount
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastFrame === null) onDragFrameChange?.(null)
|
||||
}, [lastFrame, onDragFrameChange])
|
||||
|
||||
const applyPaths = (paths: string[], replaceId: string | null = null) => {
|
||||
onChange(applyKeyframeImagePaths({
|
||||
keyframes,
|
||||
paths,
|
||||
replaceId,
|
||||
lastFrame,
|
||||
preferredFrame: playheadFrame,
|
||||
maxCount,
|
||||
}))
|
||||
}
|
||||
|
||||
const openFilePicker = (replaceId: string | null) => {
|
||||
replaceIdRef.current = replaceId
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-800/60 px-2 pb-2 pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-300">
|
||||
<ImagePlus className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span>Keyframes</span>
|
||||
<span className="text-zinc-600">{keyframes.length}/{maxCount}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addEnabled}
|
||||
title={keyframes.length >= maxCount
|
||||
? `You can place up to ${maxCount} keyframes`
|
||||
: lastFrame === null
|
||||
? 'Choose a duration to place keyframes'
|
||||
: 'Add keyframes at the playhead'}
|
||||
className="flex h-6 items-center gap-1 rounded-md border border-dashed border-zinc-700 px-2 text-[10px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => openFilePicker(null)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
if (addEnabled) setIsDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setIsDragOver(false)
|
||||
applyPaths(imagePathsFromDataTransfer(event.dataTransfer))
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className={isDragOver ? 'text-blue-300' : undefined}>Add keyframe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastFrame === null ? (
|
||||
<div className="flex h-12 items-center justify-center rounded-lg border border-dashed border-zinc-700 text-[10px] text-zinc-500">
|
||||
Choose a duration to place keyframes
|
||||
</div>
|
||||
) : (
|
||||
<KeyframeTimeline
|
||||
keyframes={keyframes}
|
||||
fps={fps}
|
||||
lastFrame={lastFrame}
|
||||
playheadFrame={Math.min(playheadFrame, lastFrame)}
|
||||
onPlayheadChange={onPlayheadChange}
|
||||
onDragFrameChange={onDragFrameChange}
|
||||
onFrameChange={(id, frameIndex) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, frameIndex } : keyframe
|
||||
)))
|
||||
onPlayheadChange(frameIndex)
|
||||
}}
|
||||
onStrengthChange={(id, strength) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, strength } : keyframe
|
||||
)))
|
||||
}}
|
||||
onReplaceRequest={(id) => openFilePicker(id)}
|
||||
onDelete={(id) => onChange(keyframes.filter((keyframe) => keyframe.id !== id))}
|
||||
onImagesDrop={(dataTransfer, replaceId) => {
|
||||
applyPaths(imagePathsFromDataTransfer(dataTransfer), replaceId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
applyPaths(imagePathsFromFiles(event.target.files ?? []), replaceIdRef.current)
|
||||
replaceIdRef.current = null
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export interface OutpaintPads {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
interface OutpaintCanvasEditorProps {
|
||||
// Source video dimensions in pixels (the silhouette, fixed — never resized).
|
||||
sourceWidth: number
|
||||
sourceHeight: number
|
||||
value: OutpaintPads
|
||||
onChange: (pads: OutpaintPads) => void
|
||||
}
|
||||
|
||||
// Square display box for the editor. The source sits centred; the output frame grows around it.
|
||||
const STAGE = 260
|
||||
// Aspect-ratio quick-snaps: expand the deficient axis to hit the ratio, centred. null = reset.
|
||||
const PRESETS: { id: string; label: string; ratio: number | null }[] = [
|
||||
{ id: 'reset', label: 'Reset', ratio: null },
|
||||
{ id: '16:9', label: '16:9', ratio: 16 / 9 },
|
||||
{ id: '1:1', label: '1:1', ratio: 1 },
|
||||
{ id: '9:16', label: '9:16', ratio: 9 / 16 },
|
||||
{ id: '21:9', label: '21:9', ratio: 21 / 9 },
|
||||
]
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
|
||||
const toEven = (v: number) => v + (v % 2)
|
||||
|
||||
// Handles: each names the edge(s) it moves ('l'/'r'/'t'/'b'). Corners move two.
|
||||
const HANDLES = ['tl', 't', 'tr', 'l', 'r', 'bl', 'b', 'br'] as const
|
||||
type Handle = (typeof HANDLES)[number]
|
||||
|
||||
export function OutpaintCanvasEditor({ sourceWidth, sourceHeight, value, onChange }: OutpaintCanvasEditorProps) {
|
||||
// Cap = +100% per axis, so the max canvas is 3× the source on each side. Fit that into the stage.
|
||||
const scale = Math.min(STAGE / (3 * sourceWidth), STAGE / (3 * sourceHeight))
|
||||
const stageW = 3 * sourceWidth * scale
|
||||
const stageH = 3 * sourceHeight * scale
|
||||
|
||||
const srcLeft = stageW / 2 - (sourceWidth * scale) / 2
|
||||
const srcTop = stageH / 2 - (sourceHeight * scale) / 2
|
||||
const frameLeft = srcLeft - value.left * scale
|
||||
const frameTop = srcTop - value.top * scale
|
||||
const frameW = (sourceWidth + value.left + value.right) * scale
|
||||
const frameH = (sourceHeight + value.top + value.bottom) * scale
|
||||
|
||||
const outW = toEven(sourceWidth + value.left + value.right)
|
||||
const outH = toEven(sourceHeight + value.top + value.bottom)
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.PointerEvent, handle: Handle) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const start = { ...value }
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dx = (ev.clientX - startX) / scale
|
||||
const dy = (ev.clientY - startY) / scale
|
||||
const next = { ...start }
|
||||
// Dragging the left/top edge outward (negative delta) grows that pad; clamp to [0, source axis].
|
||||
if (handle.includes('l')) next.left = clamp(Math.round(start.left - dx), 0, sourceWidth)
|
||||
if (handle.includes('r')) next.right = clamp(Math.round(start.right + dx), 0, sourceWidth)
|
||||
if (handle.includes('t')) next.top = clamp(Math.round(start.top - dy), 0, sourceHeight)
|
||||
if (handle.includes('b')) next.bottom = clamp(Math.round(start.bottom + dy), 0, sourceHeight)
|
||||
onChange(next)
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
},
|
||||
[value, scale, sourceWidth, sourceHeight, onChange],
|
||||
)
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(ratio: number | null) => {
|
||||
if (ratio === null) {
|
||||
onChange({ left: 0, right: 0, top: 0, bottom: 0 })
|
||||
return
|
||||
}
|
||||
const current = sourceWidth / sourceHeight
|
||||
if (current < ratio) {
|
||||
// Too tall for the ratio → widen. Cap each side at +100% (total extra ≤ 2× width).
|
||||
const extra = clamp(Math.round(sourceHeight * ratio) - sourceWidth, 0, 2 * sourceWidth)
|
||||
const half = Math.round(extra / 2)
|
||||
onChange({ left: half, right: extra - half, top: 0, bottom: 0 })
|
||||
} else {
|
||||
// Too wide → heighten.
|
||||
const extra = clamp(Math.round(sourceWidth / ratio) - sourceHeight, 0, 2 * sourceHeight)
|
||||
const half = Math.round(extra / 2)
|
||||
onChange({ left: 0, right: 0, top: half, bottom: extra - half })
|
||||
}
|
||||
},
|
||||
[sourceWidth, sourceHeight, onChange],
|
||||
)
|
||||
|
||||
// A ratio is reachable only if the +100%-per-axis cap can grow the deficient axis far enough.
|
||||
const isPresetReachable = (ratio: number | null): boolean => {
|
||||
if (ratio === null) return true
|
||||
const current = sourceWidth / sourceHeight
|
||||
return current < ratio
|
||||
? Math.round(sourceHeight * ratio) - sourceWidth <= 2 * sourceWidth
|
||||
: Math.round(sourceWidth / ratio) - sourceHeight <= 2 * sourceHeight
|
||||
}
|
||||
|
||||
// Handle display position (centre point) on the frame border, in stage coordinates.
|
||||
const handlePos = (h: Handle): { x: number; y: number } => {
|
||||
const midX = frameLeft + frameW / 2
|
||||
const midY = frameTop + frameH / 2
|
||||
const left = frameLeft
|
||||
const right = frameLeft + frameW
|
||||
const top = frameTop
|
||||
const bottom = frameTop + frameH
|
||||
switch (h) {
|
||||
case 'tl': return { x: left, y: top }
|
||||
case 't': return { x: midX, y: top }
|
||||
case 'tr': return { x: right, y: top }
|
||||
case 'l': return { x: left, y: midY }
|
||||
case 'r': return { x: right, y: midY }
|
||||
case 'bl': return { x: left, y: bottom }
|
||||
case 'b': return { x: midX, y: bottom }
|
||||
case 'br': return { x: right, y: bottom }
|
||||
}
|
||||
}
|
||||
const handleCursor: Record<Handle, string> = {
|
||||
tl: 'nwse-resize', t: 'ns-resize', tr: 'nesw-resize', l: 'ew-resize',
|
||||
r: 'ew-resize', bl: 'nesw-resize', b: 'ns-resize', br: 'nwse-resize',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-black">
|
||||
<div className="flex-1 flex items-center justify-center min-h-0 p-3">
|
||||
<div className="relative" style={{ width: stageW, height: stageH }}>
|
||||
{/* Output frame: the green-filled generate region around the source. */}
|
||||
<div
|
||||
className="absolute border border-emerald-500/60 bg-emerald-500/10"
|
||||
style={{ left: frameLeft, top: frameTop, width: frameW, height: frameH }}
|
||||
/>
|
||||
{/* Source silhouette (kept region) — fixed size, centred. */}
|
||||
<div
|
||||
className="absolute bg-zinc-400/80 border border-zinc-300"
|
||||
style={{ left: srcLeft, top: srcTop, width: sourceWidth * scale, height: sourceHeight * scale }}
|
||||
/>
|
||||
{HANDLES.map((h) => {
|
||||
const { x, y } = handlePos(h)
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onPointerDown={(e) => startDrag(e, h)}
|
||||
className="absolute w-2.5 h-2.5 -ml-1.5 -mt-1.5 rounded-sm bg-white border border-zinc-500 shadow"
|
||||
style={{ left: x, top: y, cursor: handleCursor[h], touchAction: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-zinc-800 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
{PRESETS.map((p) => {
|
||||
// Unreachable presets stay clickable but cap-to-closest (applyPreset clamps to the
|
||||
// +100% extend cap) — marked with a · and a tooltip so it's not a silent wrong ratio.
|
||||
const capped = !isPresetReachable(p.ratio)
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => applyPreset(p.ratio)}
|
||||
title={capped ? 'Limited by the +100% expand cap — lands at the closest ratio' : undefined}
|
||||
className="px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 border border-zinc-700 transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
{capped && <span className="text-amber-500/80">·</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{sourceWidth}×{sourceHeight} → {outW}×{outH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface PythonSetupProps {
|
||||
onReady: () => void
|
||||
}
|
||||
|
||||
interface SetupProgress {
|
||||
status: 'downloading' | 'extracting' | 'complete' | 'error'
|
||||
percent: number
|
||||
downloadedBytes: number
|
||||
totalBytes: number
|
||||
speed: number
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatTimeRemaining = (seconds: number): string => {
|
||||
if (!seconds || !isFinite(seconds) || seconds <= 0) return '--'
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
|
||||
return `${Math.round(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
export function PythonSetup({ onReady }: PythonSetupProps) {
|
||||
const [progress, setProgress] = useState<SetupProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [videoPath, setVideoPath] = useState('/splash/splash.mp4')
|
||||
const started = useRef(false)
|
||||
|
||||
const getTimeRemaining = (): string => {
|
||||
if (!progress || progress.speed <= 0) return '--'
|
||||
const remainingBytes = progress.totalBytes - progress.downloadedBytes
|
||||
if (remainingBytes <= 0) return '--'
|
||||
return formatTimeRemaining(remainingBytes / progress.speed)
|
||||
}
|
||||
|
||||
// Resolve video path for production
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const resourcePath = await window.electronAPI.getResourcePath?.()
|
||||
if (resourcePath) {
|
||||
setVideoPath(`file://${resourcePath}/app.asar.unpacked/dist/splash/splash.mp4`)
|
||||
}
|
||||
} catch {
|
||||
// Dev mode: use relative path
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// Listen for progress events
|
||||
useEffect(() => {
|
||||
window.electronAPI.onPythonSetupProgress((data: unknown) => {
|
||||
const p = data as SetupProgress
|
||||
setProgress(p)
|
||||
if (p.status === 'complete') {
|
||||
onReady()
|
||||
} else if (p.status === 'error') {
|
||||
setError('Download failed. Please check your internet connection and try again.')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI.removePythonSetupProgress()
|
||||
}
|
||||
}, [onReady])
|
||||
|
||||
// Start download on mount
|
||||
useEffect(() => {
|
||||
if (started.current) return
|
||||
started.current = true
|
||||
startSetup()
|
||||
}, [])
|
||||
|
||||
const startSetup = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await window.electronAPI.startPythonSetup()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to download Python environment.')
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = progress?.status === 'extracting' ? 'Extracting...' : 'Downloading Python environment...'
|
||||
const percent = progress?.percent ?? 0
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col" style={{
|
||||
background: '#000000',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
color: '#ffffff'
|
||||
}}>
|
||||
{/* Custom Title Bar */}
|
||||
<div style={{
|
||||
height: 32,
|
||||
background: '#000000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 80,
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
// @ts-expect-error - Electron-specific CSS property
|
||||
WebkitAppRegion: 'drag'
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: '#a0a0a0' }}>LTX Desktop</span>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
// @ts-expect-error - Electron-specific CSS property
|
||||
WebkitAppRegion: 'no-drag'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 32px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<svg style={{ height: 24, width: 'auto' }} viewBox="0 0 75 32" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="white"/>
|
||||
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="white"/>
|
||||
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}>
|
||||
{/* Video Section */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 140,
|
||||
background: '#0a0a0a',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<video
|
||||
key={videoPath}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
<source src={videoPath} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 140,
|
||||
background: '#0d0d0d',
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #2a2a2a'
|
||||
}}>
|
||||
{error ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 10,
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span style={{ color: '#f87171', fontSize: 13, textAlign: 'center', maxWidth: 400 }}>{error}</span>
|
||||
<button
|
||||
onClick={() => { setError(null); started.current = false; startSetup() }}
|
||||
style={{
|
||||
padding: '6px 20px',
|
||||
borderRadius: 9999,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(125deg, #A98BD9, #6D28D9)',
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: '#A98BD9', fontWeight: 600 }}>
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div style={{
|
||||
height: 6,
|
||||
background: '#1a1a1a',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(125deg, #A98BD9, #6D28D9, #194DF9)',
|
||||
backgroundSize: '200% 200%',
|
||||
animation: 'gradientShift 3s ease infinite',
|
||||
borderRadius: 3,
|
||||
width: `${percent}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: '#a0a0a0'
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{progress?.status === 'extracting'
|
||||
? 'Setting up Python environment...'
|
||||
: 'First-time setup — this only happens once'}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginLeft: 16, flexShrink: 0 }}>
|
||||
{progress && progress.speed > 0 && (
|
||||
<span style={{ color: '#6D28D9', fontWeight: 500 }}>
|
||||
{(progress.speed / (1024 * 1024)).toFixed(1)} MB/s
|
||||
</span>
|
||||
)}
|
||||
{progress && progress.downloadedBytes > 0 && (
|
||||
<span>
|
||||
{formatBytes(progress.downloadedBytes)}{progress.totalBytes > 0 ? ` / ${formatBytes(progress.totalBytes)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{progress && progress.speed > 0 && progress.totalBytes > 0 && (
|
||||
<span>
|
||||
ETA: {getTimeRemaining()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 24px',
|
||||
borderTop: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#666' }}>© 2026 Lightricks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { VideoPreviewPanel, formatTimecode, type VideoPreviewContext } from './VideoPreviewPanel'
|
||||
import { validateVideoSource } from '../lib/video-constraints'
|
||||
|
||||
interface RetakePanelProps {
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
fillHeight?: boolean
|
||||
enforceApiConstraints?: boolean
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
startTime: number
|
||||
duration: number
|
||||
videoDuration: number
|
||||
width: number
|
||||
height: number
|
||||
ready: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
const MIN_DURATION = 2
|
||||
|
||||
export function RetakePanel({
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
fillHeight = false,
|
||||
enforceApiConstraints = true,
|
||||
onChange,
|
||||
}: RetakePanelProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const filmstripRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const [selStart, setSelStart] = useState(0)
|
||||
const [selEnd, setSelEnd] = useState(Math.min(initialDuration || 0, 5))
|
||||
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | 'range' | null>(null)
|
||||
// Read synchronously by the filmstrip click guard. Stays true through the click the
|
||||
// browser fires right after a drag-release mouseup (cleared on the next tick), so
|
||||
// releasing a trim drag over the strip doesn't jump the playhead.
|
||||
const draggingRef = useRef(false)
|
||||
const dragStartRef = useRef<{ mouseX: number; selStart: number; selEnd: number } | null>(null)
|
||||
const initialSelectionAppliedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setSelStart(0)
|
||||
setSelEnd(Math.min(initialDuration || 0, 5))
|
||||
initialSelectionAppliedRef.current = false
|
||||
}, [resetKey, initialDuration])
|
||||
|
||||
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
|
||||
setVideoPath(data.videoPath)
|
||||
setVideoDuration(data.videoDuration)
|
||||
setDimensions({ width: data.width, height: data.height })
|
||||
if (!data.videoPath) {
|
||||
setSelStart(0)
|
||||
setSelEnd(0)
|
||||
initialSelectionAppliedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sourceError = enforceApiConstraints && videoPath
|
||||
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
|
||||
: null
|
||||
|
||||
// Apply the default selection once the source duration is known.
|
||||
useEffect(() => {
|
||||
if (!videoPath || videoDuration <= 0 || initialSelectionAppliedRef.current) return
|
||||
setSelStart(0)
|
||||
setSelEnd(Math.min(videoDuration, 5))
|
||||
initialSelectionAppliedRef.current = true
|
||||
}, [videoDuration, videoPath])
|
||||
|
||||
useEffect(() => {
|
||||
const ready = !!videoPath && (selEnd - selStart) >= MIN_DURATION && !sourceError
|
||||
onChange?.({
|
||||
videoPath,
|
||||
startTime: selStart,
|
||||
duration: selEnd - selStart,
|
||||
videoDuration,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
ready,
|
||||
})
|
||||
}, [videoPath, selStart, selEnd, videoDuration, dimensions, sourceError, onChange])
|
||||
|
||||
// Retake-specific keyboard: set in/out points to the playhead.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (key === 'i') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const t = video.currentTime
|
||||
if (t < selEnd - MIN_DURATION) setSelStart(t)
|
||||
} else if (key === 'o') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const t = video.currentTime
|
||||
if (t > selStart + MIN_DURATION) setSelEnd(t)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => window.removeEventListener('keydown', handler, true)
|
||||
}, [selStart, selEnd])
|
||||
|
||||
const selStartRef = useRef(selStart)
|
||||
selStartRef.current = selStart
|
||||
const selEndRef = useRef(selEnd)
|
||||
selEndRef.current = selEnd
|
||||
|
||||
const handleFilmstripMouseDown = useCallback((e: React.MouseEvent, handle: 'start' | 'end' | 'range') => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragStartRef.current = { mouseX: e.clientX, selStart: selStartRef.current, selEnd: selEndRef.current }
|
||||
draggingRef.current = true
|
||||
setDraggingHandle(handle)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingHandle) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const strip = filmstripRef.current
|
||||
const origin = dragStartRef.current
|
||||
if (!strip || !origin) return
|
||||
const rect = strip.getBoundingClientRect()
|
||||
|
||||
if (draggingHandle === 'range') {
|
||||
const dx = e.clientX - origin.mouseX
|
||||
const dtSeconds = (dx / rect.width) * videoDuration
|
||||
const rangeDuration = origin.selEnd - origin.selStart
|
||||
let newStart = origin.selStart + dtSeconds
|
||||
let newEnd = origin.selEnd + dtSeconds
|
||||
if (newStart < 0) { newStart = 0; newEnd = rangeDuration }
|
||||
if (newEnd > videoDuration) { newEnd = videoDuration; newStart = videoDuration - rangeDuration }
|
||||
setSelStart(Math.max(0, newStart))
|
||||
setSelEnd(Math.min(videoDuration, newEnd))
|
||||
} else {
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const time = fraction * videoDuration
|
||||
if (draggingHandle === 'start') {
|
||||
const maxStart = selEndRef.current - MIN_DURATION
|
||||
setSelStart(Math.max(0, Math.min(maxStart, time)))
|
||||
} else {
|
||||
const minEnd = selStartRef.current + MIN_DURATION
|
||||
setSelEnd(Math.min(videoDuration, Math.max(minEnd, time)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingHandle(null)
|
||||
dragStartRef.current = null
|
||||
// Clear on the next tick so the click synthesized from this mouseup still sees the
|
||||
// drag in progress and is suppressed by the filmstrip seek guard.
|
||||
setTimeout(() => { draggingRef.current = false }, 0)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [draggingHandle, videoDuration])
|
||||
|
||||
const renderTrimOverlay = useCallback((ctx: VideoPreviewContext) => {
|
||||
const dur = ctx.videoDuration
|
||||
const selStartFrac = dur > 0 ? selStart / dur : 0
|
||||
const selEndFrac = dur > 0 ? selEnd / dur : 1
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 bg-black/75 pointer-events-none"
|
||||
style={{ width: `${selStartFrac * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 bg-black/75 pointer-events-none"
|
||||
style={{ width: `${(1 - selEndFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-white pointer-events-none"
|
||||
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 z-[12] ${draggingHandle === 'range' ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
style={{
|
||||
left: `calc(${selStartFrac * 100}% + 14px)`,
|
||||
width: `calc(${(selEndFrac - selStartFrac) * 100}% - 28px)`,
|
||||
}}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'range')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-2 border-blue-500 pointer-events-none"
|
||||
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
|
||||
style={{ left: `calc(${selStartFrac * 100}% - 6px)`, width: '20px' }}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'start')}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
|
||||
style={{ left: '5px', width: '4px', borderRadius: '2px 0 0 2px' }}
|
||||
/>
|
||||
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '2px 0 0 0' }} />
|
||||
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '0 0 0 2px' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
|
||||
style={{ left: `calc(${selEndFrac * 100}% - 14px)`, width: '20px' }}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'end')}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
|
||||
style={{ right: '5px', width: '4px', borderRadius: '0 2px 2px 0' }}
|
||||
/>
|
||||
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 2px 0 0' }} />
|
||||
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 0 2px 0' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute top-1/2 pointer-events-none z-10"
|
||||
style={{ left: `${((selStartFrac + selEndFrac) / 2) * 100}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
<span className="text-[11px] font-mono text-zinc-700 bg-white/90 rounded px-2 py-0.5 font-semibold shadow">
|
||||
{formatTimecode(selEnd - selStart)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}, [selStart, selEnd, draggingHandle, handleFilmstripMouseDown])
|
||||
|
||||
const renderLabels = useCallback(() => (
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selStart)}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-500">Duration: {formatTimecode(selEnd - selStart)}</span>
|
||||
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selEnd)}</span>
|
||||
</div>
|
||||
), [selStart, selEnd])
|
||||
|
||||
return (
|
||||
<VideoPreviewPanel
|
||||
title="Retake"
|
||||
initialVideoPath={initialVideoPath}
|
||||
initialDuration={initialDuration}
|
||||
resetKey={resetKey}
|
||||
isProcessing={isProcessing}
|
||||
processingStatus={processingStatus}
|
||||
processingDefault="Processing retake..."
|
||||
fillHeight={fillHeight}
|
||||
emptyTitle="Drop a video to retake"
|
||||
hint={{ title: 'Select the video part to regenerate', subtitle: 'Use the prompt panel below to describe what should happen' }}
|
||||
errorMessage={sourceError ?? undefined}
|
||||
videoRef={videoRef}
|
||||
filmstripRef={filmstripRef}
|
||||
onSourceChange={handleSourceChange}
|
||||
shouldSuppressSeek={() => draggingRef.current}
|
||||
filmstripOverlay={renderTrimOverlay}
|
||||
belowFilmstrip={renderLabels}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { LoraInfoPopover } from './LoraInfoPopover'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
|
||||
type Sections = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>
|
||||
|
||||
export interface SelectedLoraItem {
|
||||
name: string
|
||||
sections?: Sections
|
||||
repoId?: string
|
||||
isCommunity?: boolean
|
||||
}
|
||||
|
||||
// The "currently selected LoRA(s)" bar shown above the prompt bar — a name + info popover
|
||||
// per item, inline. Used for both the single active IC-LoRA and the list of plain LoRAs.
|
||||
export function SelectedLoraInfo({ items }: { items: SelectedLoraItem[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-[11px] text-zinc-400">
|
||||
{items.map((it, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<span className="font-semibold text-zinc-300">{it.name}</span>
|
||||
<LoraInfoPopover
|
||||
sections={it.sections ?? []}
|
||||
name={it.name}
|
||||
repoId={it.repoId}
|
||||
isCommunity={it.isCommunity}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useFixedMenu } from '../hooks/use-fixed-menu'
|
||||
import { Tooltip } from './ui/tooltip'
|
||||
|
||||
// Generic settings dropdown used across the prompt bar's control rows.
|
||||
// The menu is portaled to document.body so overflow-hidden ancestors
|
||||
// (react-resizable-panels) cannot clip it.
|
||||
export function SettingsDropdown({
|
||||
trigger,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
tooltip,
|
||||
triggerClassName,
|
||||
placement = 'above',
|
||||
}: {
|
||||
trigger: React.ReactNode
|
||||
options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title: string
|
||||
tooltip?: string
|
||||
// Extra classes on the trigger button — e.g. to visually attach it to an adjacent button
|
||||
// as a split-button (rounded-l-none, no left padding, etc).
|
||||
triggerClassName?: string
|
||||
// Prompt-bar menus open upward; gallery toolbar menus open downward.
|
||||
placement?: 'above' | 'below'
|
||||
}) {
|
||||
const { isOpen, setIsOpen, triggerRef, menuRef, style } = useFixedMenu(placement)
|
||||
|
||||
const triggerButton = (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-1.5 rounded-md transition-colors ${isOpen ? 'bg-zinc-700 hover:bg-zinc-700' : 'hover:bg-zinc-800'} ${triggerClassName ?? ''}`}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative">
|
||||
{tooltip && !isOpen ? <Tooltip content={tooltip}>{triggerButton}</Tooltip> : triggerButton}
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={style}
|
||||
className="fixed w-max bg-zinc-800 border border-zinc-700 rounded-md p-2 min-w-[160px] shadow-xl"
|
||||
>
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-2">{title}</div>
|
||||
{/* Cap height + scroll so a long option list (e.g. many catalog / custom IC-LoRAs)
|
||||
doesn't clip off-screen — matches the LoRA picker's max-h-80. */}
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
{options.map(option => (
|
||||
<div key={option.value} className="relative group/option">
|
||||
<button
|
||||
onClick={() => { if (!option.disabled) { onChange(option.value); setIsOpen(false) } }}
|
||||
className={`w-full flex items-center justify-between px-2 py-2 rounded-md transition-colors text-left ${
|
||||
option.disabled
|
||||
? 'cursor-not-allowed'
|
||||
: value === option.value ? 'bg-white/20 hover:bg-white/25' : 'hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex items-center gap-2.5 text-sm ${
|
||||
option.disabled
|
||||
? 'text-zinc-600'
|
||||
: value === option.value ? 'text-white' : 'text-zinc-400'
|
||||
}`}>
|
||||
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
|
||||
{option.label}
|
||||
</span>
|
||||
{value === option.value && !option.disabled && (
|
||||
<svg className="w-5 h-5 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>
|
||||
{option.disabled && option.tooltip && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-zinc-700 rounded text-xs text-zinc-300 whitespace-nowrap opacity-0 group-hover/option:opacity-100 pointer-events-none z-[10000] transition-opacity">
|
||||
{option.tooltip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Select } from './ui/select'
|
||||
import { videoGenerationResolutionLabel } from '../lib/video-resolution'
|
||||
import {
|
||||
areVideoGenerationSettingsEquivalent,
|
||||
resolveVideoGenerationOptions,
|
||||
sanitizeVideoGenerationSettings,
|
||||
type VideoGenerationModelSpecItem,
|
||||
type VideoGenerationPipeline,
|
||||
} from '../lib/video-generation-model-specs'
|
||||
|
||||
export type GenerationMode = 'text-to-video' | 'image-to-video' | 'text-to-image'
|
||||
|
||||
export interface LoraSelection {
|
||||
ref: string
|
||||
name: string
|
||||
scale: number
|
||||
// Catalog id (e.g. "cozy-felt-style"), when this LoRA was picked from the library —
|
||||
// absent for a manually-added/custom LoRA not in the catalog. Lets the prompt enhancer
|
||||
// look up trigger/instructions without re-matching on filename or display name.
|
||||
catalogId?: string
|
||||
}
|
||||
|
||||
export interface GenerationSettings {
|
||||
model: VideoGenerationPipeline
|
||||
duration: number | null
|
||||
videoResolution: string
|
||||
fps: number
|
||||
audio: boolean
|
||||
cameraMotion: string
|
||||
aspectRatio?: string
|
||||
loras?: LoraSelection[]
|
||||
// Image-specific settings
|
||||
imageResolution: string
|
||||
imageAspectRatio: string
|
||||
imageSteps: number
|
||||
variations?: number // Number of image variations to generate
|
||||
imageEditStrength?: number // Denoising strength when editing an existing image
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
settings: GenerationSettings
|
||||
onSettingsChange: (settings: GenerationSettings) => void
|
||||
disabled?: boolean
|
||||
mode?: GenerationMode
|
||||
hasAudio?: boolean
|
||||
videoModelSpecs?: VideoGenerationModelSpecItem[] | null
|
||||
minimumDuration?: number
|
||||
hideDuration?: boolean
|
||||
videoSettingsMessage?: string | null
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
disabled,
|
||||
mode = 'text-to-video',
|
||||
hasAudio = false,
|
||||
videoModelSpecs,
|
||||
minimumDuration,
|
||||
hideDuration = false,
|
||||
videoSettingsMessage,
|
||||
}: SettingsPanelProps) {
|
||||
const isImageMode = mode === 'text-to-image'
|
||||
const resolvedVideoOptions = useMemo(() => {
|
||||
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return null
|
||||
return resolveVideoGenerationOptions({
|
||||
settings,
|
||||
modelSpecs: videoModelSpecs,
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
}, [hasAudio, hideDuration, isImageMode, minimumDuration, settings, videoModelSpecs])
|
||||
|
||||
useEffect(() => {
|
||||
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return
|
||||
const sanitized = sanitizeVideoGenerationSettings(settings, videoModelSpecs, {
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
if (!sanitized) return
|
||||
if (!areVideoGenerationSettingsEquivalent(settings, sanitized)) {
|
||||
onSettingsChange(sanitized)
|
||||
}
|
||||
}, [hasAudio, hideDuration, isImageMode, minimumDuration, onSettingsChange, settings, videoModelSpecs])
|
||||
|
||||
const handleChange = (key: keyof GenerationSettings, value: string | number | boolean | null) => {
|
||||
if (isImageMode) {
|
||||
onSettingsChange({ ...settings, [key]: value } as GenerationSettings)
|
||||
return
|
||||
}
|
||||
if (!videoModelSpecs || videoModelSpecs.length === 0) return
|
||||
|
||||
const nextSettings = { ...settings, [key]: value } as GenerationSettings
|
||||
const sanitized = sanitizeVideoGenerationSettings(nextSettings, videoModelSpecs, {
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
if (sanitized) {
|
||||
onSettingsChange(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Image mode settings
|
||||
if (isImageMode) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Aspect Ratio and Quality side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Aspect Ratio"
|
||||
value={settings.imageAspectRatio || '16:9'}
|
||||
onChange={(e) => handleChange('imageAspectRatio', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="1:1">1:1 (Square)</option>
|
||||
<option value="16:9">16:9 (Landscape)</option>
|
||||
<option value="9:16">9:16 (Portrait)</option>
|
||||
<option value="4:3">4:3 (Standard)</option>
|
||||
<option value="3:4">3:4 (Portrait Standard)</option>
|
||||
<option value="21:9">21:9 (Cinematic)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Quality"
|
||||
value={settings.imageSteps || 4}
|
||||
onChange={(e) => handleChange('imageSteps', parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value={4}>Fast</option>
|
||||
<option value={8}>Balanced</option>
|
||||
<option value={12}>High</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!videoModelSpecs || videoModelSpecs.length === 0 || !resolvedVideoOptions || !resolvedVideoOptions.hasCompatibleOptions) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs text-zinc-500">
|
||||
{videoSettingsMessage || 'Loading generation settings...'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showFps = resolvedVideoOptions.fpsOptions.length > 1
|
||||
const videoControlsColumns = hideDuration
|
||||
? (showFps ? 'grid-cols-2' : 'grid-cols-1')
|
||||
: (showFps ? 'grid-cols-3' : 'grid-cols-2')
|
||||
|
||||
// Video mode settings
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Model"
|
||||
value={resolvedVideoOptions.selectedModel ?? settings.model}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.modelOptions.map((item) => (
|
||||
<option key={item.pipeline} value={item.pipeline}>
|
||||
{item.spec.display_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Duration, Resolution, FPS Row */}
|
||||
<div className={`grid gap-3 ${videoControlsColumns}`}>
|
||||
{!hideDuration && (
|
||||
<Select
|
||||
label="Duration"
|
||||
value={
|
||||
resolvedVideoOptions.autoDurationAvailable && resolvedVideoOptions.selectedDuration === null
|
||||
? 'auto'
|
||||
: String(resolvedVideoOptions.selectedDuration ?? settings.duration)
|
||||
}
|
||||
onChange={(e) => handleChange('duration', e.target.value === 'auto' ? null : parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.autoDurationAvailable && (
|
||||
<option value="auto">Auto</option>
|
||||
)}
|
||||
{resolvedVideoOptions.durationOptions.map((duration) => (
|
||||
<option key={duration} value={duration}>
|
||||
{duration} sec
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Resolution"
|
||||
value={resolvedVideoOptions.selectedResolution ?? settings.videoResolution}
|
||||
onChange={(e) => handleChange('videoResolution', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.resolutionOptions.map((resolution) => (
|
||||
<option key={resolution} value={resolution}>
|
||||
{videoGenerationResolutionLabel(resolution)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{showFps && (
|
||||
<Select
|
||||
label="FPS"
|
||||
value={resolvedVideoOptions.selectedFps ?? settings.fps}
|
||||
onChange={(e) => handleChange('fps', parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.fpsOptions.map((fps) => (
|
||||
<option key={fps} value={fps}>
|
||||
{fps}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aspect Ratio */}
|
||||
<Select
|
||||
label="Aspect Ratio"
|
||||
value={settings.aspectRatio || '16:9'}
|
||||
onChange={(e) => handleChange('aspectRatio', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="16:9">16:9 Landscape</option>
|
||||
<option value="9:16">9:16 Portrait</option>
|
||||
</Select>
|
||||
|
||||
{/* Audio and Camera Motion Row */}
|
||||
<div className="flex gap-3">
|
||||
<div className="w-[140px] flex-shrink-0">
|
||||
<Select
|
||||
label="Audio"
|
||||
badge="PREVIEW"
|
||||
value={settings.audio ? 'on' : 'off'}
|
||||
onChange={(e) => handleChange('audio', e.target.value === 'on')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Camera Motion"
|
||||
value={settings.cameraMotion}
|
||||
onChange={(e) => handleChange('cameraMotion', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="focus_shift">Focus Shift</option>
|
||||
<option value="dolly_in">Dolly In</option>
|
||||
<option value="dolly_out">Dolly Out</option>
|
||||
<option value="dolly_left">Dolly Left</option>
|
||||
<option value="dolly_right">Dolly Right</option>
|
||||
<option value="jib_up">Jib Up</option>
|
||||
<option value="jib_down">Jib Down</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
.update-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: update-modal-backdrop-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.update-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(63 63 70);
|
||||
background: rgb(24 24 27);
|
||||
color: white;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
animation: update-modal-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.update-modal h2 {
|
||||
margin: 0 1.5rem 0.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.update-modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: rgb(161 161 170);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-modal-close:hover {
|
||||
background: rgb(39 39 42);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-modal-version {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.update-modal-notes {
|
||||
margin-top: 0.75rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(9 9 11 / 0.6);
|
||||
border: 1px solid rgb(63 63 70 / 0.6);
|
||||
color: rgb(212 212 216);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.update-modal-error,
|
||||
.update-modal-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.update-modal-error {
|
||||
color: rgb(248 113 113);
|
||||
}
|
||||
|
||||
.update-modal-warning {
|
||||
color: rgb(251 191 36);
|
||||
}
|
||||
|
||||
.update-modal-progress {
|
||||
position: relative;
|
||||
margin-top: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.update-modal-progress::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgb(39 39 42);
|
||||
}
|
||||
|
||||
.update-modal-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgb(43 97 255);
|
||||
transition: width 150ms ease;
|
||||
}
|
||||
|
||||
.update-modal-progress span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.update-modal-skip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(212 212 216);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-modal-skip input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.update-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.update-modal-actions button {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes update-modal-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes update-modal-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Download, RefreshCw, X } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import type { AppUpdate } from '../hooks/use-app-update'
|
||||
import './UpdateAvailableModal.css'
|
||||
|
||||
interface Props {
|
||||
update: AppUpdate
|
||||
isGenerationActive: boolean
|
||||
// onClose receives whether the user ticked "Skip this version".
|
||||
onClose: (skipThisVersion: boolean) => void
|
||||
}
|
||||
|
||||
function titleForStatus(status: AppUpdate['state']['status']): string {
|
||||
if (status === 'downloaded') return 'Ready to install'
|
||||
if (status === 'downloading') return 'Downloading update'
|
||||
if (status === 'checking') return 'Checking for updates'
|
||||
return 'Update available'
|
||||
}
|
||||
|
||||
export function UpdateAvailableModal({ update, isGenerationActive, onClose }: Props) {
|
||||
const { state, startDownload, installAndRestart } = update
|
||||
const [skipChecked, setSkipChecked] = useState(false)
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const downloading = state.status === 'downloading'
|
||||
const downloaded = state.status === 'downloaded'
|
||||
const canDownload = state.status === 'available'
|
||||
const canDismiss = !downloaded
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!canDismiss) return
|
||||
onClose(skipChecked && state.status === 'available')
|
||||
}, [canDismiss, onClose, skipChecked, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && canDismiss) handleClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [canDismiss, handleClose])
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
const res = await installAndRestart()
|
||||
if (!res.success) setInstallError(res.error ?? 'Could not install the update.')
|
||||
} catch (e) {
|
||||
setInstallError(e instanceof Error ? e.message : 'Could not install the update.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="update-modal-backdrop"
|
||||
onClick={canDismiss ? handleClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="update-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-modal-title"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canDismiss && (
|
||||
<button className="update-modal-close" onClick={handleClose} aria-label="Close">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 id="update-modal-title">{titleForStatus(state.status)}</h2>
|
||||
<p className="update-modal-version">
|
||||
{state.currentVersion} → <strong>{state.version}</strong>
|
||||
</p>
|
||||
|
||||
{state.releaseNotes && <div className="update-modal-notes">{state.releaseNotes}</div>}
|
||||
|
||||
{state.message && (
|
||||
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {state.message}</p>
|
||||
)}
|
||||
{installError && (
|
||||
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {installError}</p>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="update-modal-bar" style={{ width: `${state.percent ?? 0}%` }} />
|
||||
<span>{state.percent ?? 0}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloaded && isGenerationActive && (
|
||||
<p className="update-modal-warning">
|
||||
A generation is running. Installing will restart the app — it will be enabled when the
|
||||
generation finishes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'available' && (
|
||||
<label className="update-modal-skip">
|
||||
<input type="checkbox" checked={skipChecked} onChange={(e) => setSkipChecked(e.target.checked)} />
|
||||
Skip this version
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="update-modal-actions">
|
||||
{downloaded ? (
|
||||
<Button onClick={handleInstall} disabled={isGenerationActive}>
|
||||
<RefreshCw className="h-4 w-4" /> Restart to update
|
||||
</Button>
|
||||
) : downloading ? (
|
||||
<Button variant="ghost" onClick={handleClose}>Hide</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handleClose}>Later</Button>
|
||||
<Button onClick={startDownload} disabled={!canDownload}>
|
||||
{state.message ? (<><Download className="h-4 w-4" /> Try again</>)
|
||||
: (<><Download className="h-4 w-4" /> Update now</>)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Film, Play, Pause, Volume2, VolumeX, Loader2, Upload, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { logger } from '../lib/logger'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
|
||||
// Shared video source + preview surface used by RetakePanel and ExtendPanel. Owns the
|
||||
// video file (drop/browse/clear), playback, mute, the timecode transport bar, and the
|
||||
// filmstrip thumbnail strip with click-to-seek. Mode-specific UI (retake's trim handles,
|
||||
// extend's direction/seconds controls) is injected via the render-prop slots.
|
||||
|
||||
export interface VideoPreviewContext {
|
||||
videoPath: string | null
|
||||
videoUrl: string | null
|
||||
videoDuration: number
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
thumbnails: string[]
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
filmstripRef: React.RefObject<HTMLDivElement>
|
||||
seek: (seconds: number) => void
|
||||
}
|
||||
|
||||
interface VideoPreviewPanelProps {
|
||||
title: string
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
processingDefault?: string
|
||||
fillHeight?: boolean
|
||||
emptyTitle?: string
|
||||
hint?: { title: string; subtitle?: string }
|
||||
// Validation error for the current source; when set, shown as a banner.
|
||||
errorMessage?: string
|
||||
// Owned by a parent when it needs them in its own effects (e.g. retake drag/keyboard).
|
||||
videoRef?: React.RefObject<HTMLVideoElement>
|
||||
filmstripRef?: React.RefObject<HTMLDivElement>
|
||||
onSourceChange?: (data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => void
|
||||
// Returns true when the parent is mid-interaction (e.g. dragging a trim handle) and the
|
||||
// filmstrip click-to-seek should be ignored — a drag release lands a click on the strip.
|
||||
shouldSuppressSeek?: () => boolean
|
||||
// Absolutely-positioned overlay rendered on top of the filmstrip thumbnails.
|
||||
filmstripOverlay?: (ctx: VideoPreviewContext) => React.ReactNode
|
||||
// Content rendered below the filmstrip (labels for retake, controls for extend).
|
||||
belowFilmstrip?: (ctx: VideoPreviewContext) => React.ReactNode
|
||||
}
|
||||
|
||||
export function formatTimecode(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`
|
||||
}
|
||||
|
||||
export function VideoPreviewPanel({
|
||||
title,
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
processingDefault = 'Processing...',
|
||||
fillHeight = false,
|
||||
emptyTitle = 'Drop a video',
|
||||
hint,
|
||||
errorMessage,
|
||||
videoRef: videoRefProp,
|
||||
filmstripRef: filmstripRefProp,
|
||||
onSourceChange,
|
||||
shouldSuppressSeek,
|
||||
filmstripOverlay,
|
||||
belowFilmstrip,
|
||||
}: VideoPreviewPanelProps) {
|
||||
const internalVideoRef = useRef<HTMLVideoElement>(null)
|
||||
const internalFilmstripRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = videoRefProp ?? internalVideoRef
|
||||
const filmstripRef = filmstripRefProp ?? internalFilmstripRef
|
||||
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const videoUrl = videoPath ? pathToFileUrl(videoPath) : null
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([])
|
||||
const [thumbCount] = useState(20)
|
||||
const extractingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setVideoPath(initialVideoPath || null)
|
||||
setVideoDuration(initialDuration || 0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
extractingRef.current = false
|
||||
}, [resetKey, initialVideoPath, initialDuration])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) {
|
||||
setVideoDuration(0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
extractingRef.current = false
|
||||
}
|
||||
}, [videoUrl, initialDuration])
|
||||
|
||||
useEffect(() => {
|
||||
onSourceChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height })
|
||||
}, [videoPath, videoDuration, dimensions, onSourceChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl || extractingRef.current || videoDuration <= 0) return
|
||||
extractingRef.current = true
|
||||
let cancelled = false
|
||||
|
||||
const extractThumbnails = async () => {
|
||||
const video = document.createElement('video')
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.preload = 'auto'
|
||||
video.muted = true
|
||||
video.src = videoUrl
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadeddata = () => resolve()
|
||||
video.onerror = () => reject(new Error('Failed to load video for thumbnails'))
|
||||
setTimeout(() => reject(new Error('Timeout loading video')), 10000)
|
||||
})
|
||||
if (cancelled) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const thumbWidth = 80
|
||||
const thumbHeight = Math.round(thumbWidth * (video.videoHeight / video.videoWidth))
|
||||
canvas.width = thumbWidth
|
||||
canvas.height = thumbHeight
|
||||
|
||||
const frames: string[] = []
|
||||
const count = Math.min(thumbCount, Math.max(5, Math.floor(videoDuration / 0.25)))
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const seekTime = (i / count) * videoDuration
|
||||
video.currentTime = seekTime
|
||||
await new Promise<void>(resolve => {
|
||||
video.onseeked = () => resolve()
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
if (cancelled) { video.src = ''; video.load(); return }
|
||||
ctx.drawImage(video, 0, 0, thumbWidth, thumbHeight)
|
||||
frames.push(canvas.toDataURL('image/jpeg', 0.6))
|
||||
}
|
||||
|
||||
video.src = ''
|
||||
video.load()
|
||||
setThumbnails(frames)
|
||||
}
|
||||
|
||||
extractThumbnails().catch(err => {
|
||||
logger.warn(`Filmstrip extraction failed: ${err}`)
|
||||
})
|
||||
|
||||
// Abort an in-flight extraction when the source changes or the component unmounts.
|
||||
// Reset the re-entry guard too: videoDuration is refined by loadedmetadata after the
|
||||
// asset's approximate duration kicks off the first run, and without this the cancelled
|
||||
// run leaves the guard stuck true and the restart never happens (permanent spinner).
|
||||
return () => {
|
||||
cancelled = true
|
||||
extractingRef.current = false
|
||||
}
|
||||
}, [videoUrl, videoDuration, thumbCount])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const handler = () => setCurrentTime(video.currentTime)
|
||||
const onLoaded = () => {
|
||||
// The real file duration is authoritative; initialDuration is only a pre-load hint
|
||||
// (an asset's stored duration can be stale/approximate and mismatch the actual file).
|
||||
if (video.duration && Number.isFinite(video.duration)) {
|
||||
setVideoDuration(video.duration)
|
||||
}
|
||||
if (video.videoWidth && video.videoHeight) {
|
||||
setDimensions({ width: video.videoWidth, height: video.videoHeight })
|
||||
}
|
||||
}
|
||||
video.addEventListener('timeupdate', handler)
|
||||
video.addEventListener('loadedmetadata', onLoaded)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handler)
|
||||
video.removeEventListener('loadedmetadata', onLoaded)
|
||||
}
|
||||
}, [videoUrl])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
video.play()
|
||||
setIsPlaying(true)
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.muted = !video.muted
|
||||
setIsMuted(video.muted)
|
||||
}, [])
|
||||
|
||||
// Shared transport keyboard: play/pause + frame/scrub. (Retake adds its own i/o keys.)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
const video = videoRef.current
|
||||
if (key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
togglePlay()
|
||||
} else if (key === 'arrowleft') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
video.currentTime = Math.max(0, video.currentTime - 1 / 24)
|
||||
}
|
||||
} else if (key === 'arrowright') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
video.currentTime = Math.min(videoDuration, video.currentTime + 1 / 24)
|
||||
}
|
||||
} else if (key === 'k') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) { video.pause(); setIsPlaying(false) }
|
||||
} else if (key === 'l') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) { video.play(); setIsPlaying(true) }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => window.removeEventListener('keydown', handler, true)
|
||||
}, [togglePlay, videoDuration])
|
||||
|
||||
const seek = useCallback((seconds: number) => {
|
||||
const video = videoRef.current
|
||||
if (video) video.currentTime = seconds
|
||||
}, [])
|
||||
|
||||
const handleFilmstripClick = useCallback((e: React.MouseEvent) => {
|
||||
if (shouldSuppressSeek?.()) return
|
||||
const strip = filmstripRef.current
|
||||
const video = videoRef.current
|
||||
if (!strip || !video) return
|
||||
const rect = strip.getBoundingClientRect()
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
video.currentTime = fraction * videoDuration
|
||||
}, [videoDuration, shouldSuppressSeek])
|
||||
|
||||
// Swap to a new source. Reset duration/dimensions too (not just thumbnails) so the next
|
||||
// onSourceChange doesn't emit the new path with the previous file's metadata until
|
||||
// loadedmetadata fires.
|
||||
const selectSource = useCallback((path: string) => {
|
||||
setVideoPath(path)
|
||||
setVideoDuration(0)
|
||||
setCurrentTime(0)
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
setThumbnails([])
|
||||
extractingRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Select Video',
|
||||
filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }],
|
||||
})
|
||||
if (paths && paths.length > 0) {
|
||||
selectSource(paths[0])
|
||||
}
|
||||
}, [selectSource])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setVideoPath(null)
|
||||
setVideoDuration(0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
extractingRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === 'video' && asset.path) {
|
||||
selectSource(asset.path)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through to file handling
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) {
|
||||
selectSource(filePath)
|
||||
}
|
||||
}
|
||||
}, [selectSource])
|
||||
|
||||
const ctx: VideoPreviewContext = {
|
||||
videoPath,
|
||||
videoUrl,
|
||||
videoDuration,
|
||||
currentTime,
|
||||
isPlaying,
|
||||
thumbnails,
|
||||
videoRef,
|
||||
filmstripRef,
|
||||
seek,
|
||||
}
|
||||
|
||||
const playheadFrac = videoDuration > 0 ? currentTime / videoDuration : 0
|
||||
|
||||
return (
|
||||
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Film className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-white">{title}</span>
|
||||
{videoPath && (
|
||||
<span className="text-xs text-zinc-500 truncate max-w-[240px]">
|
||||
{videoPath.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{videoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Clear video"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Replace video"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!videoUrl ? (
|
||||
<div
|
||||
className={`p-8 flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl m-4 transition-colors ${
|
||||
isDragOver ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-700'
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-zinc-800">
|
||||
<Upload className="h-5 w-5 text-zinc-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-white">{emptyTitle}</p>
|
||||
<p className="text-xs text-zinc-500">mp4, mov, avi, webm, mkv</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-md bg-white text-black hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative bg-black flex-1 min-h-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1.5 rounded bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-1 rounded hover:bg-zinc-800 text-white transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<span className="text-xs font-mono text-zinc-400">
|
||||
{formatTimecode(currentTime)} / {formatTimecode(videoDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hint && (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<p className="text-xs font-semibold text-white">{hint.title}</p>
|
||||
{hint.subtitle && <p className="text-[10px] text-zinc-500 mt-0.5">{hint.subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<div className="relative h-3 mb-0">
|
||||
<div
|
||||
className="absolute pointer-events-none z-10"
|
||||
style={{ left: `${playheadFrac * 100}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polygon points="0,0 10,0 5,8" fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={filmstripRef}
|
||||
className="relative h-14 rounded-md overflow-hidden cursor-pointer select-none"
|
||||
onClick={handleFilmstripClick}
|
||||
>
|
||||
<div className="absolute inset-0 flex">
|
||||
{thumbnails.length > 0 ? (
|
||||
thumbnails.map((thumb, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="h-full flex-1 object-cover"
|
||||
style={{ minWidth: 0 }}
|
||||
draggable={false}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 text-zinc-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filmstripOverlay?.(ctx)}
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-zinc-800 pointer-events-none z-[15]"
|
||||
style={{ left: `${playheadFrac * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{belowFilmstrip?.(ctx)}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-600/10 border border-red-500/20">
|
||||
<span className="text-xs text-red-300">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isProcessing ? (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600/10 border border-blue-500/20">
|
||||
<Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin flex-shrink-0" />
|
||||
<span className="text-xs text-blue-300">{processingStatus || processingDefault}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
export class WebErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() { return { failed: true } }
|
||||
render() {
|
||||
if (this.state.failed) return <main className="p-12 space-y-4"><h1 className="text-2xl">LTX DeskWEB could not render this view</h1><p>Your saved projects remain in this browser. Reload to return to the project list.</p><button className="px-4 py-2 bg-blue-600 rounded" onClick={() => location.reload()}>Reload workspace</button></main>
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { migrateProjectData } from '../types/project-model'
|
||||
import { readProjectIds, writeProject, writeProjectIds, getProjectStorageKey } from '../lib/project-storage'
|
||||
|
||||
export function WebProjectBackup() {
|
||||
const [error, setError] = useState('')
|
||||
return <div className="flex gap-4 items-center flex-wrap">
|
||||
<button className="underline" onClick={() => {
|
||||
const data: Record<string, string> = { 'ltx-project-ids': JSON.stringify(readProjectIds()) }
|
||||
for (const id of readProjectIds()) { const key = getProjectStorageKey(id), item = localStorage.getItem(key); if (item) data[key] = item }
|
||||
void window.electronAPI.saveFile({ filePath: 'ltx-projects-backup.json', data: JSON.stringify(data, null, 2) })
|
||||
}}>Back up projects (JSON)</button>
|
||||
<label className="underline cursor-pointer">Restore project backup
|
||||
<input type="file" accept=".json" className="hidden" onChange={async event => {
|
||||
const file = event.target.files?.[0]; if (!file) return
|
||||
setError('')
|
||||
try {
|
||||
if (file.size > 20 * 1024 * 1024) throw Error('Backup is too large (maximum 20 MiB)')
|
||||
const data = JSON.parse(await file.text()) as Record<string, string>
|
||||
const ids: unknown = JSON.parse(data['ltx-project-ids'])
|
||||
if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !/^project-[a-zA-Z0-9-]+$/.test(id))) throw Error('Invalid project index')
|
||||
const projects = ids.map(id => { const result = migrateProjectData(JSON.parse(data[getProjectStorageKey(id)])).project; if (result.id !== id) throw Error('Project ID mismatch'); return result })
|
||||
if (!confirm(`Restore ${projects.length} project(s)? Projects with matching IDs in this browser will be replaced. Media files are not included in the backup.`)) return
|
||||
for (const project of projects) writeProject(project.id, project)
|
||||
writeProjectIds([...new Set([...ids as string[], ...readProjectIds()])]); location.reload()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Cannot restore backup') }
|
||||
event.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-red-300">{error}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Download, ExternalLink, Check, Loader2, Sparkles } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { catalogVariantKey, preferredVariantId } from '@/lib/lora-library'
|
||||
|
||||
export type LibraryItemStatus =
|
||||
| { kind: 'available' }
|
||||
| { kind: 'downloading'; progress: number }
|
||||
| { kind: 'downloaded' }
|
||||
| { kind: 'error'; gated?: boolean; message: string }
|
||||
|
||||
export interface LibraryItemVariant {
|
||||
id: string
|
||||
label: string
|
||||
sizeBytes: number
|
||||
downloaded?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
sizeBytes?: number
|
||||
/** Item-level fallback when there are no variants (any file present). */
|
||||
downloaded: boolean
|
||||
thumbnailUrl?: string
|
||||
demoVideoUrl?: string
|
||||
author?: { name: string; url?: string | null }
|
||||
license?: { name: string; url?: string | null }
|
||||
variants?: LibraryItemVariant[]
|
||||
defaultVariantId?: string
|
||||
requiresHfLogin?: boolean
|
||||
}
|
||||
|
||||
interface LibraryItemCardProps {
|
||||
item: LibraryItem
|
||||
selected?: boolean
|
||||
// The variant actually active for this entry (when selected). Undefined/null means
|
||||
// "don't check" (e.g. no variant picker) — badge follows `selected` alone.
|
||||
selectedVariantId?: string | null
|
||||
downloadingKey?: string | null
|
||||
progress?: number
|
||||
downloadError?: { key: string; message: string; gated?: boolean } | null
|
||||
onDownload?: (id: string, variantId?: string) => void
|
||||
onUse?: (id: string, variantId?: string) => void
|
||||
onRequestAccess?: (id: string) => void
|
||||
onRetry?: (id: string, variantId?: string) => void
|
||||
infoSlot?: ReactNode
|
||||
}
|
||||
|
||||
const openUrl = (url?: string | null) => { if (url) void window.electronAPI.openExternalUrl({ url }) }
|
||||
|
||||
// Generic media grid card for one downloadable asset. No domain knowledge — the consumer
|
||||
// maps its type into a LibraryItem. Media is optional: a demo video shows its first frame
|
||||
// (preload=metadata) and plays on hover; without media the card shows the thumbnail or a placeholder.
|
||||
export function LibraryItemCard({
|
||||
item, selected, selectedVariantId, downloadingKey, progress = 0, downloadError,
|
||||
onDownload, onUse, onRequestAccess, onRetry, infoSlot,
|
||||
}: LibraryItemCardProps) {
|
||||
const hasVariants = (item.variants?.length ?? 0) > 1
|
||||
const downloadedVariantIds = useMemo(
|
||||
() => (item.variants ?? []).filter(v => v.downloaded).map(v => v.id),
|
||||
[item.variants],
|
||||
)
|
||||
const downloadedKey = downloadedVariantIds.join(',')
|
||||
const fallbackVariantId = item.defaultVariantId ?? item.variants?.[0]?.id
|
||||
const initialVariantId = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
|
||||
?? fallbackVariantId
|
||||
const [variantId, setVariantId] = useState(initialVariantId)
|
||||
// Keep selection on an installed checkpoint when install state arrives after first paint
|
||||
// (e.g. listModels refresh) and the catalog default still isn't on disk.
|
||||
useEffect(() => {
|
||||
if (!hasVariants) return
|
||||
const preferred = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
|
||||
setVariantId(prev => {
|
||||
if (prev && downloadedVariantIds.includes(prev)) return prev
|
||||
if (prev && item.variants?.some(v => v.id === prev)) return prev
|
||||
return preferred ?? fallbackVariantId
|
||||
})
|
||||
}, [hasVariants, item.defaultVariantId, item.variants, downloadedKey, fallbackVariantId]) // eslint-disable-line react-hooks/exhaustive-deps -- downloadedKey proxies ids
|
||||
|
||||
const selectedVariant = item.variants?.find(v => v.id === variantId)
|
||||
const sizeBytes = selectedVariant?.sizeBytes ?? item.sizeBytes
|
||||
const size = sizeBytes ? formatBytes(sizeBytes) : null
|
||||
const activeKey = catalogVariantKey(item.id, hasVariants ? variantId : undefined)
|
||||
const status: LibraryItemStatus = (() => {
|
||||
if (downloadingKey === activeKey) return { kind: 'downloading', progress }
|
||||
if (downloadError?.key === activeKey) {
|
||||
return {
|
||||
kind: 'error',
|
||||
gated: downloadError.gated ?? Boolean(item.requiresHfLogin),
|
||||
message: downloadError.message,
|
||||
}
|
||||
}
|
||||
if (hasVariants) {
|
||||
return selectedVariant?.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
|
||||
}
|
||||
return item.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
|
||||
})()
|
||||
const selectedVariantReady = status.kind === 'downloaded'
|
||||
// With a variant picker, only badge "Selected" when the dropdown is on the variant
|
||||
// that's actually active — not just this catalog entry (avoids marking the wrong checkpoint).
|
||||
const isSelected = Boolean(selected) && (!hasVariants || selectedVariantId == null || selectedVariantId === variantId)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
// Lazy media: don't fetch/decode the demo video until the card scrolls near the viewport.
|
||||
const [near, setNear] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = cardRef.current
|
||||
if (!el || near) return
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) setNear(true)
|
||||
}, { rootMargin: '200px' })
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [near])
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (hovered) void v.play().catch(() => {})
|
||||
else { v.pause(); v.currentTime = 0 }
|
||||
}, [hovered])
|
||||
|
||||
// Keep the variant picker visible while another variant downloads.
|
||||
const showVariantPicker = hasVariants && status.kind !== 'downloading'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-colors ${isSelected ? 'border-amber-500/60 bg-amber-500/10' : 'border-zinc-700 bg-zinc-800/50'}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Media */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-zinc-950">
|
||||
{item.demoVideoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={near || hovered ? item.demoVideoUrl : undefined}
|
||||
poster={item.thumbnailUrl}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.thumbnailUrl ? (
|
||||
<img src={item.thumbnailUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
|
||||
<Sparkles className="h-6 w-6 text-zinc-700" />
|
||||
</div>
|
||||
)}
|
||||
{size && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-zinc-300">{size}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-white" title={item.title}>{item.title}</span>
|
||||
{infoSlot}
|
||||
</div>
|
||||
{item.description && <div className="line-clamp-2 text-[11px] text-zinc-500">{item.description}</div>}
|
||||
{(item.author || item.license) && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-zinc-500">
|
||||
{item.author && (
|
||||
item.author.url
|
||||
? <button onClick={() => openUrl(item.author!.url)} className="text-zinc-400 hover:text-white transition-colors">by {item.author.name}</button>
|
||||
: <span>by {item.author.name}</span>
|
||||
)}
|
||||
{item.license && (
|
||||
item.license.url
|
||||
? <button onClick={() => openUrl(item.license!.url)} className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300 hover:bg-zinc-700 transition-colors">{item.license.name}</button>
|
||||
: <span className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">{item.license.name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: status / action */}
|
||||
<div className="mt-auto pt-1.5 flex flex-col gap-1.5">
|
||||
{showVariantPicker && (
|
||||
<select
|
||||
value={variantId}
|
||||
onChange={(e) => setVariantId(e.target.value)}
|
||||
className="w-full rounded bg-zinc-900 border border-zinc-700 px-2 py-1 text-[11px] text-zinc-200"
|
||||
aria-label={`${item.title} variant`}
|
||||
>
|
||||
{item.variants!.map(v => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.downloaded ? `${v.label} ✓` : v.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{status.kind === 'downloading' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {Math.round(status.progress)}%
|
||||
</span>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${status.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-[11px] text-red-400" title={status.message}>
|
||||
{status.gated ? 'This model is gated — request access, then retry.' : status.message}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{status.gated && (
|
||||
<button
|
||||
onClick={() => onRequestAccess?.(item.id)}
|
||||
className="inline-flex items-center gap-1 rounded bg-amber-600 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-500"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Request access
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRetry?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className="inline-flex items-center gap-1 rounded bg-zinc-700 px-2 py-1 text-[11px] font-medium text-zinc-200 transition-colors hover:bg-zinc-600"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status.kind !== 'downloading' && status.kind !== 'error' && selectedVariantReady && (
|
||||
<button
|
||||
onClick={() => onUse?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className={`inline-flex w-full items-center justify-center gap-1 rounded px-3 py-1.5 text-xs font-medium transition-colors ${isSelected ? 'bg-amber-600 text-white' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}`}
|
||||
>
|
||||
{isSelected ? <><Check className="h-3 w-3" /> Selected</> : 'Use'}
|
||||
</button>
|
||||
)}
|
||||
{status.kind !== 'downloading' && status.kind !== 'error' && !selectedVariantReady && (
|
||||
<button
|
||||
onClick={() => onDownload?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className="inline-flex w-full items-center justify-center gap-1 rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface LibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
headerSlot?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Generic library dialog shell — no asset/recipe knowledge. A future regular-LoRA
|
||||
// library reuses it.
|
||||
export function LibraryModal({ open, onClose, title, headerSlot, children }: LibraryModalProps) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (open && !el.open) el.showModal()
|
||||
else if (!open && el.open) el.close()
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
// Clicking the backdrop targets the <dialog> element itself (content is the inner div).
|
||||
onClick={(e) => { if (e.target === ref.current) onClose() }}
|
||||
className="m-auto w-[min(960px,calc(100%-2rem))] max-h-[80vh] overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900 p-0 text-white backdrop:bg-black/60"
|
||||
>
|
||||
{/* Flex column so only the body scrolls — no hardcoded header-height calc. */}
|
||||
<div className="flex max-h-[80vh] flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerSlot}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">{children}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { AlertCircle, Check, Download, Folder, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppSettings } from '../../contexts/AppSettingsContext'
|
||||
import { useHfAuth } from '../../hooks/use-hf-auth'
|
||||
import { useHfModelAccess } from '../../hooks/use-hf-model-access'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../../lib/api-client'
|
||||
import { formatBytes } from '../../lib/format'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { HfModelAccessGate } from '../HfModelAccessGate'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
type LtxModelVersionItem = ApiSuccessOf<'getLtxVersions'>['versions'][number]
|
||||
type ModelCheckpointID = NonNullable<
|
||||
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
|
||||
>[number]
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
|
||||
const DOWNLOAD_POLL_INTERVAL_MS = 1000
|
||||
|
||||
function VersionRow({
|
||||
version,
|
||||
onChanged,
|
||||
resumeSessionId,
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
}: {
|
||||
version: LtxModelVersionItem
|
||||
onChanged: () => Promise<void>
|
||||
resumeSessionId: string | null
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [downloadPercent, setDownloadPercent] = useState(0)
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => () => { mountedRef.current = false }, [])
|
||||
|
||||
const cpsToDownload = useMemo(
|
||||
() => (version.installed ? [] : (version.cps_to_download as ModelCheckpointID[])),
|
||||
[version.installed, version.cps_to_download],
|
||||
)
|
||||
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
|
||||
cpsToDownload,
|
||||
hfAuthStatus,
|
||||
)
|
||||
const canDownload = version.installed || (allAuthorized && !checkingAccess)
|
||||
|
||||
const adoptedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (resumeSessionId && resumeSessionId !== adoptedRef.current && !downloadSessionId) {
|
||||
adoptedRef.current = resumeSessionId
|
||||
setDownloadSessionId(resumeSessionId)
|
||||
setBusy(true)
|
||||
}
|
||||
}, [resumeSessionId, downloadSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadSessionId) return
|
||||
let cancelled = false
|
||||
|
||||
const poll = async () => {
|
||||
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
logger.error(`Progress poll error: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
const progress = result.data
|
||||
if (progress.status === 'downloading') {
|
||||
setDownloadPercent(Math.round(progress.total_progress))
|
||||
return
|
||||
}
|
||||
if (progress.status === 'error') {
|
||||
setDownloadSessionId(null)
|
||||
setBusy(false)
|
||||
setError(progress.error || 'Download failed.')
|
||||
return
|
||||
}
|
||||
if (progress.status === 'complete') {
|
||||
setDownloadSessionId(null)
|
||||
setDownloadPercent(100)
|
||||
await onChanged()
|
||||
if (!cancelled) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
const interval = setInterval(() => void poll(), DOWNLOAD_POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [downloadSessionId, onChanged])
|
||||
|
||||
const handleSetActive = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.setActiveLtxModel({ model_id: version.model_id })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to set active model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_id, onChanged])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
setDownloadPercent(0)
|
||||
const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: version.cps_to_download })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to start download.')
|
||||
return
|
||||
}
|
||||
if (result.data.status !== 'started') {
|
||||
setBusy(false)
|
||||
setError('Unexpected response while starting download.')
|
||||
return
|
||||
}
|
||||
setDownloadSessionId(result.data.sessionId)
|
||||
}, [version.cps_to_download])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.deleteModels({ cp_ids: [version.model_cp] })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to delete model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_cp, onChanged])
|
||||
|
||||
const isDownloading = downloadSessionId !== null
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800/50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="active-ltx-model"
|
||||
checked={version.active}
|
||||
disabled={!version.installed || version.active || busy}
|
||||
onChange={() => void handleSetActive()}
|
||||
className="h-4 w-4 accent-blue-500 flex-shrink-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-white truncate">{version.label}</span>
|
||||
{version.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 inline-flex items-center gap-1 flex-shrink-0">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{version.is_newest && !version.installed && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 flex-shrink-0">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!version.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={busy || !canDownload}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isDownloading ? `Downloading… ${downloadPercent}%` : `Download ${formatBytes(version.size_bytes)}`}
|
||||
</Button>
|
||||
)}
|
||||
{version.installed && !version.active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy}
|
||||
className="border-zinc-700 text-zinc-300 hover:text-red-400 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!version.installed && (
|
||||
<HfModelAccessGate
|
||||
accessMap={accessMap}
|
||||
allAuthorized={allAuthorized}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={startHuggingFaceLogin}
|
||||
checkError={checkError}
|
||||
onRetryCheck={recheckAccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BaseModelSection() {
|
||||
const [versions, setVersions] = useState<LtxModelVersionItem[]>([])
|
||||
const [modelsDir, setModelsDir] = useState('')
|
||||
const [activeDownload, setActiveDownload] = useState<{ sessionId: string; cpIds: string[] } | null>(null)
|
||||
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
|
||||
const { notifyModelsChanged } = useAppSettings()
|
||||
const knownActiveRef = useRef<string | null>(null)
|
||||
|
||||
const refreshVersions = useCallback(async () => {
|
||||
const [versionsResult, activeResult] = await Promise.all([
|
||||
ApiClient.getLtxVersions(),
|
||||
ApiClient.getActiveDownload(),
|
||||
])
|
||||
if (!versionsResult.ok) {
|
||||
logger.error(`Failed to fetch LTX versions: ${versionsResult.error.message}`)
|
||||
return
|
||||
}
|
||||
setVersions(versionsResult.data.versions)
|
||||
// Signal only on a real change so mounting the panel doesn't refetch generation specs.
|
||||
const nextActive = versionsResult.data.versions.find((item) => item.active)?.model_id ?? null
|
||||
const nextKey = `${nextActive}|${versionsResult.data.versions.filter((item) => item.installed).map((item) => item.model_id).join(',')}`
|
||||
if (knownActiveRef.current !== null && knownActiveRef.current !== nextKey) {
|
||||
notifyModelsChanged()
|
||||
}
|
||||
knownActiveRef.current = nextKey
|
||||
if (activeResult.ok) {
|
||||
setActiveDownload(
|
||||
activeResult.data.session_id
|
||||
? { sessionId: activeResult.data.session_id, cpIds: activeResult.data.cp_ids ?? [] }
|
||||
: null,
|
||||
)
|
||||
}
|
||||
}, [notifyModelsChanged])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshVersions()
|
||||
void (async () => {
|
||||
const result = await ApiClient.getSettings()
|
||||
if (!result.ok) {
|
||||
logger.error(`Failed to fetch settings: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
setModelsDir(result.data.modelsDir ?? '')
|
||||
})()
|
||||
}, [refreshVersions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Models Folder</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
Where model checkpoints are stored. Changing the location requires restarting the app.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-sm truncate select-text">
|
||||
{modelsDir || <span className="text-zinc-600">Not set</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.openModelsDirChangeDialog()
|
||||
if (result.success) {
|
||||
setModelsDir(result.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change…
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
disabled={!modelsDir}
|
||||
onClick={() => {
|
||||
void window.electronAPI.openModelsFolder()
|
||||
}}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Base Model</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
The active version is used for new generations. Download a version to make it available,
|
||||
then set it active. Newer versions may require a Hugging Face sign-in.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-xs text-zinc-600">No versions available.</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<VersionRow
|
||||
key={version.model_id}
|
||||
version={version}
|
||||
onChanged={refreshVersions}
|
||||
resumeSessionId={
|
||||
activeDownload && activeDownload.cpIds.includes(version.model_cp)
|
||||
? activeDownload.sessionId
|
||||
: null
|
||||
}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={() => {
|
||||
void startHuggingFaceLogin()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-red-500 text-white shadow-sm hover:bg-red-600',
|
||||
outline: 'border border-border bg-transparent shadow-sm hover:bg-secondary',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-secondary hover:text-secondary-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, showLabel = false, ...props }, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block text-right">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, badge, children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<label className="flex items-center gap-2 text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4 h-4">
|
||||
{label}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-zinc-700 text-zinc-300 rounded border border-zinc-600 font-semibold leading-none">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full appearance-none rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white',
|
||||
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'pr-8 cursor-pointer',
|
||||
'[&>option]:bg-zinc-800 [&>option]:text-white [&>option:disabled]:text-zinc-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export { Select }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
helperText?: string
|
||||
charCount?: number
|
||||
maxChars?: number
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, helperText, charCount, maxChars, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[120px] w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-3 text-sm text-white',
|
||||
'placeholder:text-zinc-500',
|
||||
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'resize-y',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex justify-between mt-2">
|
||||
{helperText && (
|
||||
<span className="text-xs text-zinc-500">{helperText}</span>
|
||||
)}
|
||||
{maxChars !== undefined && (
|
||||
<span className="text-xs text-zinc-500 ml-auto">
|
||||
{charCount ?? 0}/{maxChars}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TooltipProps {
|
||||
content: React.ReactNode
|
||||
children: React.ReactNode
|
||||
/** Which side of the trigger the tooltip appears on. Default: 'top' */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DELAY_MS = 500
|
||||
const GAP_PX = 6
|
||||
|
||||
/**
|
||||
* Styled tooltip with 250ms show delay and instant hide.
|
||||
* Renders via a portal into document.body so it is never clipped
|
||||
* by overflow-hidden ancestors.
|
||||
*/
|
||||
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [style, setStyle] = useState<React.CSSProperties>({})
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const computeStyle = useCallback(() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||
if (!rect) return {}
|
||||
switch (side) {
|
||||
case 'top':
|
||||
return { left: rect.left + rect.width / 2, top: rect.top - GAP_PX, transform: 'translate(-50%, -100%)' }
|
||||
case 'bottom':
|
||||
return { left: rect.left + rect.width / 2, top: rect.bottom + GAP_PX, transform: 'translate(-50%, 0)' }
|
||||
case 'left':
|
||||
return { left: rect.left - GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(-100%, -50%)' }
|
||||
case 'right':
|
||||
return { left: rect.right + GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(0, -50%)' }
|
||||
}
|
||||
}, [side])
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setStyle(computeStyle() ?? {})
|
||||
setVisible(true)
|
||||
}, DELAY_MS)
|
||||
}, [computeStyle])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
||||
setVisible(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn('inline-flex', className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
{visible && ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] px-2.5 py-1.5 bg-white text-zinc-800 text-xs font-medium rounded-md shadow-md whitespace-nowrap pointer-events-none select-none"
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user