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

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+221
View File
@@ -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>
)
}
+166
View File
@@ -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,
)
}
+386
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}
+671
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
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 &amp; 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>
)
}
+71
View File
@@ -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}
/>
)
}
+28
View File
@@ -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
+81
View File
@@ -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>
)
}
+98
View File
@@ -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&apos;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>
)
}
+751
View File
@@ -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>
)
}
+139
View File
@@ -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>
</>
)
}
+769
View File
@@ -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 &middot; 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>
)
}
+49
View File
@@ -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>
)
}
+276
View File
@@ -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>
)
}
+283
View File
@@ -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>
)
}
+108
View File
@@ -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>
</>
)
}
+123
View File
@@ -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>
)
}
+72
View File
@@ -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()}
/>
)
}
+18
View File
@@ -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>
)
}
+65
View File
@@ -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%);
}
}
+383
View File
@@ -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>
)
}
+251
View File
@@ -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>
)
}
+134
View File
@@ -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>
)
}
+300
View File
@@ -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' }}>&copy; 2026 Lightricks</div>
</div>
</div>
</div>
)
}
+279
View File
@@ -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}
/>
)
}
+32
View File
@@ -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>
)
}
+93
View File
@@ -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
+271
View File
@@ -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>
)
}
+506
View File
@@ -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>
)
}
+9
View File
@@ -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
}
}
+32
View File
@@ -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>
</>
)
}
+48
View File
@@ -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 }
+40
View File
@@ -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 }
+47
View File
@@ -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 }
+48
View File
@@ -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 }
+75
View File
@@ -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>
)
}