import { AlertCircle, Check, Download, Film, Folder, HardDrive, Info, KeyRound, Settings, Sparkles, X, Zap } from 'lucide-react' import React, { useEffect, useMemo, useRef, useState } from 'react' import { Button } from './ui/button' import { BaseModelSection } from './settings/BaseModelSection' import { useAppSettings, type AppSettings, DEFAULT_GEMINI_MODEL } from '../contexts/AppSettingsContext' import { ApiClient, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' import { ApiKeyHelperRow, LtxApiKeyInput, LtxApiKeyHelperRow } from './LtxApiKeyInput' import { HfModelAccessGate } from './HfModelAccessGate' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' import type { AppUpdate } from '../hooks/use-app-update' import type { UpdateStatePayload } from '../../shared/electron-api-schema' export type SettingsInitialReason = 'geminiKeyRequired' interface SettingsModalProps { isOpen: boolean onClose: () => void initialTab?: TabId initialReason?: SettingsInitialReason update: AppUpdate onOpenUpdate: () => void onCheckForUpdates: () => void } type TabId = 'general' | 'models' | 'apiKeys' | 'promptEnhancer' | 'about' /** A checkpoint this modal can download: the text encoder or the optional prompt enhancer. */ type TextEncodingCp = NonNullable['cp_to_download']> type GeminiModelOption = ApiSuccessOf<'listGeminiModels'>['models'][number] /** Focuses an API Keys tab input once the modal has switched to that tab. * Shared by the LTX and FAL key inputs — each call gets its own ref/pending state. * Pass `sectionRef` + `containerRef` to scroll a whole section (e.g. Gemini heading + banner) * into the tab body; focus then uses preventScroll so the input doesn't yank the banner back * off-screen. */ function useApiKeyFocus( isOpen: boolean, activeTab: TabId, setActiveTab: (tab: TabId) => void, sectionRef?: React.RefObject, containerRef?: React.RefObject, ) { const inputRef = useRef(null) const [pending, setPending] = useState(false) useEffect(() => { if (!pending) return // Closing the modal or leaving API Keys cancels the timeout below; drop `pending` so a // later open can't replay scroll/focus that nobody requested this time. if (!isOpen || activeTab !== 'apiKeys') { setPending(false) return } // Wait for the API Keys tab to paint before scrolling. Clearing `pending` in this // effect body used to cancel the scheduled work on the very next render. const timeoutId = window.setTimeout(() => { const section = sectionRef?.current const container = containerRef?.current if (section && container) { const top = container.scrollTop + section.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) } inputRef.current?.focus({ preventScroll: sectionRef != null }) setPending(false) }, sectionRef ? 100 : 0) return () => { window.clearTimeout(timeoutId) } }, [activeTab, pending, isOpen, sectionRef, containerRef]) const openAndFocus = () => { setActiveTab('apiKeys') setPending(true) } return { inputRef, openAndFocus } } /** A labelled on/off setting row: bolt icon, title, description, switch, and a status pill. * Shared by the CUDA-only Torch Compile + Diffusion Stage Cache toggles. */ function SettingToggle({ title, description, enabled, onToggle, statusOn, statusOff }: { title: string description: React.ReactNode enabled: boolean onToggle: () => void statusOn: string statusOff: string }) { return (

{description}

{enabled ? statusOn : statusOff}
) } function GeminiModelSelect({ disabled, models, value, onChange, }: { disabled: boolean models: GeminiModelOption[] value: string onChange: (id: string) => void }) { const options = models.some((model) => model.id === value) ? models : [...models, { id: value, displayName: value, description: '' }] const selectedDescription = options.find((model) => model.id === value)?.description?.trim() ?? '' return (

Gemini chat models only — used for Enhance (API) and timeline gap suggestions. Local Enhance still uses Gemma on-device.

{selectedDescription ? (

{selectedDescription}

) : null}
) } const ABOUT_ACTION_CLASS = 'w-full bg-zinc-700 hover:bg-zinc-600 text-white text-xs' function aboutUpdateAction( state: UpdateStatePayload, onOpenUpdate: () => void, onCheckForUpdates: () => void, isMac: boolean, autoCheckOn: boolean, ): { label: string; onClick?: () => void; disabled?: boolean } { if (isMac) { // No modal: Check only forces a lookup. Download/install still follow the toggle. switch (state.status) { case 'checking': return { label: 'Checking…', disabled: true } case 'downloading': return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } case 'downloaded': return { label: autoCheckOn ? 'Will install when you quit' : 'Will still install when you quit', disabled: true, } default: return { label: 'Check for updates', onClick: autoCheckOn ? onCheckForUpdates : undefined, disabled: !autoCheckOn, } } } switch (state.status) { case 'available': return { label: `Update available — v${state.version}`, onClick: onOpenUpdate } case 'downloaded': return { label: 'Restart to update', onClick: onOpenUpdate } case 'checking': return { label: 'Checking…', disabled: true } case 'downloading': return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } default: return { label: 'Check for updates', onClick: onCheckForUpdates } } } export function SettingsModal({ isOpen, onClose, initialTab, initialReason, update, onOpenUpdate, onCheckForUpdates }: SettingsModalProps) { const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, refreshSettings, forceApiGenerations, cudaAvailable, notifyModelsChanged } = useAppSettings() const onSettingsChange = (next: AppSettings) => updateSettings(next) const [activeTab, setActiveTab] = useState('general') const tabBodyRef = useRef(null) const geminiSectionRef = useRef(null) const ltxApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab) const falApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab) const geminiApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab, geminiSectionRef, tabBodyRef) const [ltxApiKeyInput, setLtxApiKeyInput] = useState('') const [falApiKeyInput, setFalApiKeyInput] = useState('') const [geminiApiKeyInput, setGeminiApiKeyInput] = useState('') const showGeminiKeyBanner = initialReason === 'geminiKeyRequired' const [geminiModelOptions, setGeminiModelOptions] = useState([]) const [resolvedGeminiModel, setResolvedGeminiModel] = useState(DEFAULT_GEMINI_MODEL) const geminiModelSaveSeq = useRef(0) const [textEncoderRecommendation, setTextEncoderRecommendation] = useState | null>(null) // Which checkpoint is downloading, not just whether one is — the encoder and the optional // prompt enhancer each have their own card and must show progress only on their own. const [downloadingCp, setDownloadingCp] = useState(null) const [downloadError, setDownloadError] = useState(null) const [downloadSessionId, setDownloadSessionId] = useState(null) const [downloadProgress, setDownloadProgress] = useState | null>(null) const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin, handleHuggingFaceLogout } = useHfAuth(isOpen) const textEncoderModelTypes = useMemo( () => (forceApiGenerations || !textEncoderRecommendation?.cp_to_download ? [] : [textEncoderRecommendation.cp_to_download]), [forceApiGenerations, textEncoderRecommendation?.cp_to_download], ) const { accessMap: teAccessMap, allAuthorized: teAllAuthorized, checkError: teCheckError, recheckAccess: recheckTeAccess } = useHfModelAccess(textEncoderModelTypes, hfAuthStatus) const preferredEnhancerDownloaded = textEncoderRecommendation !== null && textEncoderRecommendation.local_enhancer_cp !== null && textEncoderRecommendation.active_local_enhancer_cp === textEncoderRecommendation.local_enhancer_cp const enhancerCpToDownload = textEncoderRecommendation !== null && textEncoderRecommendation.local_enhancer_cp !== null && !preferredEnhancerDownloaded ? textEncoderRecommendation.local_enhancer_cp : null const enhancerModelTypes = useMemo( () => (enhancerCpToDownload === null ? [] : [enhancerCpToDownload]), [enhancerCpToDownload], ) const { accessMap: enhancerAccessMap, allAuthorized: enhancerAllAuthorized, checkError: enhancerCheckError, recheckAccess: recheckEnhancerAccess } = useHfModelAccess(enhancerModelTypes, hfAuthStatus) const apiEncodingSupported = textEncoderRecommendation?.api_encoding_supported ?? true const localEncoderSelected = settings.useLocalTextEncoder || !apiEncodingSupported const [appVersion, setAppVersion] = useState('') const [noticesText, setNoticesText] = useState(null) const [noticesLoading, setNoticesLoading] = useState(false) const [showNotices, setShowNotices] = useState(false) const [modelLicenseText, setModelLicenseText] = useState(null) const [modelLicenseLoading, setModelLicenseLoading] = useState(false) const [showModelLicense, setShowModelLicense] = useState(false) const [analyticsEnabled, setAnalyticsEnabled] = useState(false) const [autoCheckUpdates, setAutoCheckUpdatesState] = useState(true) const [projectAssetsPath, setProjectAssetsPath] = useState('') const isMac = window.electronAPI.platform === 'darwin' const updateAction = aboutUpdateAction(update.state, onOpenUpdate, onCheckForUpdates, isMac, autoCheckUpdates) // Sync active tab with initialTab prop when modal opens useEffect(() => { if (isOpen && initialTab) { setActiveTab(initialTab) } }, [isOpen, initialTab]) useEffect(() => { if (isOpen && initialReason === 'geminiKeyRequired') { geminiApiKey.openAndFocus() } }, [isOpen, initialReason]) // The Models tab is hidden in force-API mode; don't let the selection get stuck there // (e.g. via initialTab or a stale value). useEffect(() => { if (forceApiGenerations && activeTab === 'models') { setActiveTab('general') } }, [forceApiGenerations, activeTab]) // Fetch app version when About tab is shown useEffect(() => { if (activeTab !== 'about' || appVersion) return window.electronAPI.getAppInfo().then(info => setAppVersion(info.version)).catch(() => {}) }, [activeTab, appVersion]) // Fetch analytics state when modal opens useEffect(() => { if (!isOpen) return window.electronAPI.getAnalyticsState() .then((state: { analyticsEnabled: boolean }) => setAnalyticsEnabled(state.analyticsEnabled)) .catch(() => {}) window.electronAPI.getProjectAssetsPath() .then((p: string) => setProjectAssetsPath(p)) .catch(() => {}) window.electronAPI.getAutoCheckUpdates() .then((s: { enabled: boolean }) => setAutoCheckUpdatesState(s.enabled)) .catch(() => {}) }, [isOpen]) useEffect(() => { const fallbackId = settings.geminiModel.trim() || DEFAULT_GEMINI_MODEL if (!isOpen) return if (!settings.hasGeminiApiKey) { setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) setResolvedGeminiModel(fallbackId) return } let cancelled = false const loadGeminiModels = async () => { const result = await ApiClient.listGeminiModels() if (cancelled) return if (!result.ok) { setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) setResolvedGeminiModel(fallbackId) return } setGeminiModelOptions(result.data.models) setResolvedGeminiModel(result.data.resolvedModel) } void loadGeminiModels() return () => { cancelled = true } // Selecting a model calls refreshSettings(), which updates settings.geminiModel. Relisting // on that change would hit the backend on every pick and wipe the dropdown if the GET failed. // eslint-disable-next-line react-hooks/exhaustive-deps -- fallbackId is only used when the key is missing or the list fails }, [isOpen, settings.hasGeminiApiKey]) // Fetch text encoder recommendation when modal opens useEffect(() => { if (!isOpen || forceApiGenerations) return const fetchRecommendation = async () => { const result = await ApiClient.getTextEncoderRecommendation() if (!result.ok) { logger.error(`Failed to fetch text encoder recommendation: ${result.error.message}`) return } const data = result.data setTextEncoderRecommendation(data) // A download that finished elsewhere (another surface, or before this modal opened) would // otherwise leave its card stuck showing progress. const stillPending = [ data.cp_to_download, data.local_enhancer_cp !== null && data.active_local_enhancer_cp !== data.local_enhancer_cp ? data.local_enhancer_cp : null, ] setDownloadingCp((cp) => (cp !== null && !stillPending.includes(cp) ? null : cp)) } void fetchRecommendation() }, [forceApiGenerations, isOpen]) // Poll download progress via session ID useEffect(() => { if (downloadingCp === null || !downloadSessionId) return const poll = async () => { const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId }) if (!result.ok) return setDownloadProgress(result.data) if (result.data.status === 'complete') { setDownloadingCp(null) setDownloadSessionId(null) const rec = await ApiClient.getTextEncoderRecommendation() if (rec.ok) setTextEncoderRecommendation(rec.data) // Enhance reads local availability outside this modal, so it has to be told the set of // installed checkpoints changed. notifyModelsChanged() } else if (result.data.status === 'error') { setDownloadError(result.data.error ?? 'Download failed') setDownloadingCp(null) setDownloadSessionId(null) } } void poll() const interval = setInterval(() => { void poll() }, 1000) return () => clearInterval(interval) }, [downloadingCp, downloadSessionId, notifyModelsChanged]) const handleDownloadCheckpoint = async (cpId: TextEncodingCp) => { setDownloadingCp(cpId) setDownloadError(null) setDownloadProgress(null) const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: [cpId] }) if (!result.ok) { setDownloadError(result.error.message) setDownloadingCp(null) return } if (result.data.status === 'started') { setDownloadSessionId(result.data.sessionId) } } if (!isOpen) return null const handleToggleTorchCompile = () => { onSettingsChange({ ...settings, useTorchCompile: !settings.useTorchCompile, }) } const handleToggleDiffusionStageCache = () => { onSettingsChange({ ...settings, diffusionStageCacheEnabled: !settings.diffusionStageCacheEnabled, }) } const handleToggleFastDecode = () => { onSettingsChange({ ...settings, useConvVae: !settings.useConvVae, }) } const handleToggleLocalEncoder = () => { onSettingsChange({ ...settings, useLocalTextEncoder: !settings.useLocalTextEncoder, }) } const handlePromptCacheSizeChange = (e: React.ChangeEvent) => { const size = Math.max(0, Math.min(1000, parseInt(e.target.value) || 100)) onSettingsChange({ ...settings, promptCacheSize: size, }) } // Prompt Enhancer handlers const handleTogglePromptEnhancer = (mode: 't2v' | 'i2v') => { if (mode === 't2v') { onSettingsChange({ ...settings, promptEnhancerEnabledT2V: !settings.promptEnhancerEnabledT2V }) } else { onSettingsChange({ ...settings, promptEnhancerEnabledI2V: !settings.promptEnhancerEnabledI2V }) } } // Analytics handler const handleToggleAnalytics = () => { const next = !analyticsEnabled setAnalyticsEnabled(next) window.electronAPI.setAnalyticsEnabled({ enabled: next }).catch(() => {}) } const handleToggleAutoCheck = () => { const next = !autoCheckUpdates setAutoCheckUpdatesState(next) window.electronAPI.setAutoCheckUpdates({ enabled: next }).catch(() => {}) } // Seed handlers const handleToggleSeedLock = () => { onSettingsChange({ ...settings, seedLocked: !settings.seedLocked, }) } const handleLockedSeedChange = (e: React.ChangeEvent) => { const value = parseInt(e.target.value) || 0 onSettingsChange({ ...settings, lockedSeed: Math.max(0, Math.min(2147483647, value)), }) } const handleRandomizeSeed = () => { onSettingsChange({ ...settings, lockedSeed: Math.floor(Math.random() * 2147483647), }) } const handleLoadModelLicense = async () => { setModelLicenseLoading(true) try { const text = await window.electronAPI.fetchLicenseText() setModelLicenseText(text) setShowModelLicense(true) } catch (e) { logger.error(`Failed to load model license: ${e}`) } finally { setModelLicenseLoading(false) } } const handleLoadNotices = async () => { setNoticesLoading(true) try { const text = await window.electronAPI.getNoticesText() setNoticesText(text) setShowNotices(true) } catch (e) { logger.error(`Failed to load notices: ${e}`) } finally { setNoticesLoading(false) } } const tabs = [ { id: 'general' as TabId, label: 'General', icon: Settings }, // The Models tab is local-model management — irrelevant (and non-functional) when all // generation is forced through the API, so hide it in that mode. ...(!forceApiGenerations ? [{ id: 'models' as TabId, label: 'Models', icon: HardDrive }] : []), { id: 'apiKeys' as TabId, label: 'API Keys', icon: KeyRound }, { id: 'promptEnhancer' as TabId, label: 'Prompt Enhancer', icon: Sparkles }, { id: 'about' as TabId, label: 'About', icon: Info }, ] return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}

Settings

{/* Tabs */}
{tabs.map((tab) => { const Icon = tab.icon return ( ) })}
{/* Content */}
{activeTab === 'general' && ( <> {/* Project Assets Path */}

Project Assets Path

Where generated video and image assets are saved. Each project gets a subfolder.

{projectAssetsPath || Not set}
{!forceApiGenerations && (

Videos Generation

{ if (!settings.hasLtxApiKey) { ltxApiKey.openAndFocus() return } onSettingsChange({ ...settings, userPrefersLtxApiVideoGenerations: !settings.userPrefersLtxApiVideoGenerations, }) }} >
Generate With API

Use LTX API for video generation when an LTX API key is configured.

{settings.userPrefersLtxApiVideoGenerations && }
{!settings.hasLtxApiKey && (
API key required — configure it in the API Keys tab.
)}
)} {!forceApiGenerations && (

Images Generation

{ if (!settings.hasFalApiKey) { falApiKey.openAndFocus() return } onSettingsChange({ ...settings, userPrefersFalApiImageGenerations: !settings.userPrefersFalApiImageGenerations, }) }} >
Generate With API

Use the FAL API for image generation and editing when a FAL API key is configured.

{settings.userPrefersFalApiImageGenerations && }
{!settings.hasFalApiKey && (
API key required — configure it in the API Keys tab.
)}
)} {/* Text Encoding Section */} {!forceApiGenerations && (

Text Encoding

Text encoding converts your prompt into data the AI understands. Choose how to do this.

{/* LTX API Option (Default) */}
{ if (!apiEncodingSupported) return if (!settings.useLocalTextEncoder) return if (!settings.hasLtxApiKey) { ltxApiKey.openAndFocus() return } handleToggleLocalEncoder() }} >
LTX API {apiEncodingSupported ? ( Recommended ) : ( Unavailable )}

Fast cloud-based text encoding (~1 second). Requires an LTX API key configured in the API Keys tab.

{!localEncoderSelected && }
{!apiEncodingSupported && (
Not available for LTX {textEncoderRecommendation?.ltx_version_label ?? ''} — prompts for this version can only be encoded by the local encoder.
)} {/* Warning when selected but no key */} {apiEncodingSupported && !settings.useLocalTextEncoder && !settings.hasLtxApiKey && (
API key required — configure it in the API Keys tab.
)} {/* Prompt Cache Size — only relevant for API text encoding */} {!localEncoderSelected && settings.hasLtxApiKey && (

Skip repeat encoding calls

e.stopPropagation()} className="w-16 px-2 py-1 bg-zinc-700 border border-zinc-600 rounded text-xs text-white text-center focus:outline-none focus:ring-2 focus:ring-blue-500" />
)}
{/* Local Encoder Option */}
!settings.useLocalTextEncoder && handleToggleLocalEncoder()} >
Local Encoder {!apiEncodingSupported && ( Required )}

Run on your computer (slower than the API). Requires{' '} {textEncoderRecommendation?.expected_size_gb ?? '~25'} GB download.

{localEncoderSelected && }
{/* Download Status - show when this option is selected */} {localEncoderSelected && (
{textEncoderRecommendation?.cp_to_download === null ? (
Downloaded ({textEncoderRecommendation?.expected_size_gb ?? 0} GB)
) : downloadingCp === textEncoderRecommendation?.cp_to_download ? (
Downloading text encoder... {downloadProgress?.status === 'downloading' ? Math.round(downloadProgress.current_file_progress) : 0}%
) : (
Not downloaded ({textEncoderRecommendation?.expected_size_gb || 0} GB required)
{ void startHuggingFaceLogin() }} checkError={teCheckError} onRetryCheck={recheckTeAccess} className="space-y-1.5 mb-2" /> {downloadError && (

{downloadError}

)}
)}
)}
{/* Optional local prompt enhancer — only for models whose encoder can't generate */} {textEncoderRecommendation?.local_enhancer_cp && (
Local Prompt Enhancer Optional

LTX {textEncoderRecommendation.ltx_version_label}'s text encoder can only encode prompts, so enhancing them on your computer needs a separate instruct model. Gemma 3 already downloaded for 2.3 works; Gemma 4 E2B is the smaller optional upgrade. Without either, the Enhance button can still use Gemini if you have a key, and Generate uses the prompt as typed.

{preferredEnhancerDownloaded ? (
Downloaded ({textEncoderRecommendation.local_enhancer_expected_size_gb ?? 0} GB)
) : downloadingCp === textEncoderRecommendation.local_enhancer_cp ? (
Downloading prompt enhancer... {downloadProgress?.status === 'downloading' ? Math.round(downloadProgress.current_file_progress) : 0}%
) : (
{textEncoderRecommendation.local_enhancement_supported && (
Using Gemma 3 already on disk
)} { void startHuggingFaceLogin() }} checkError={enhancerCheckError} onRetryCheck={recheckEnhancerAccess} className="space-y-1.5 mb-2" /> {downloadError && (

{downloadError}

)}
)}
)}
)} {/* Fast decode — all platforms. Swaps the 2.5 video VAE; takes effect on next load. */} {/* Torch Compile + Diffusion Stage Cache -- CUDA only, no-op on MPS/CPU */} {cudaAvailable && ( Compiles the model for optimized inference. Experimental: First generation can take 5-10+ minutes for compilation. Subsequent generations may be 20-40% faster. Requires app restart to take effect.} enabled={settings.useTorchCompile} onToggle={handleToggleTorchCompile} statusOn="Optimized inference (recommended)" statusOff="Standard inference" /> )} {cudaAvailable && ( Reuses an already-built transformer across stage 1/stage 2 within one generation instead of reloading it from disk twice. Experimental: only applies on high-VRAM cards (32GB+); no effect otherwise.} enabled={settings.diffusionStageCacheEnabled} onToggle={handleToggleDiffusionStageCache} statusOn="Skipping redundant transformer reloads" statusOff="Standard behavior" /> )} {/* Seed Lock Setting */}

Use the same seed for reproducible generations. When unlocked, a random seed is used each time.

{/* Toggle Switch */}
{/* Seed input - only show when locked */} {settings.seedLocked && (
)} {/* Status indicator */}
{settings.seedLocked ? `Seed locked: ${settings.lockedSeed ?? 42}` : 'Random seed each generation'}
{/* Anonymous Analytics Setting */}

Share anonymous usage data to help improve LTX Desktop. Only basic technical information is collected — never personal data or generated content.

{/* Toggle Switch */}
)} {activeTab === 'models' && !forceApiGenerations && } {activeTab === 'apiKeys' && ( <> {/* LTX API Key Section */}

LTX API

Your LTX API key is used for cloud text encoding, prompt enhancement, and API video generation. Add your key below to unlock these features.

setLtxApiKeyInput(e.target.value)} placeholder={settings.hasLtxApiKey ? 'Enter new key to replace...' : 'Enter your LTX API key...'} stopPropagation className="flex-1" />
{settings.hasLtxApiKey ? ( <> Key configured ) : ( <> API key required )}
{/* FAL API Key Section */}

FAL AI

Optional

Your FAL AI key is used for generating or editing images with Z Image Turbo when API generations are enabled.

setFalApiKeyInput(e.target.value)} placeholder={settings.hasFalApiKey ? 'Enter new key to replace...' : 'Enter your FAL AI API key...'} stopPropagation className="flex-1" />
window.electronAPI.openFalApiKeyPage()} />
{settings.hasFalApiKey ? ( <> Key configured ) : ( <> Optional )}
{/* Gemini API Key Section */}

Gemini API

{showGeminiKeyBanner && (
Add a Gemini API key to use Enhance (API).
)}

Your Gemini API key is used for AI-powered prompt suggestions when filling timeline gaps, and for the Enhance (API) prompt enhancer.

setGeminiApiKeyInput(e.target.value)} placeholder={settings.hasGeminiApiKey ? 'Enter new key to replace...' : 'Enter your Gemini API key...'} onKeyDown={(e) => e.stopPropagation()} className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
{settings.hasGeminiApiKey ? ( <> Key configured ) : ( <> API key required )}
{ const previous = resolvedGeminiModel const requestId = ++geminiModelSaveSeq.current setResolvedGeminiModel(geminiModel) void (async () => { const result = await ApiClient.updateSettings({ geminiModel }) if (requestId !== geminiModelSaveSeq.current) return if (!result.ok) { setResolvedGeminiModel(previous) return } await refreshSettings() })() }} />
{/* HuggingFace Account */}

HuggingFace

Sign in to download gated models (such as LTX 2.5) and accept Hugging Face licenses.

{hfAuthStatus === 'authenticated' ? ( <> Signed in ) : ( <> Not signed in )}
{hfAuthStatus === 'authenticated' ? ( ) : ( )}
)} {activeTab === 'promptEnhancer' && ( <>

Prompt Enhancer

When enabled, Generate rewrites your prompt with visual detail, sound, and camera motion before the model sees it. Local generations use the on-device enhancer; LTX API text encoding enhances on the server. The Enhance button in Gen Space is separate — it rewrites the prompt box so you can edit it first. Control independently for each generation type.

{!settings.hasLtxApiKey && (

An LTX API key is only needed when text encoding goes through the LTX API. Local generations use the local enhancer instead (download it under Models if this version ships one separately).

)} {/* T2V Toggle */}
handleTogglePromptEnhancer('t2v')} >
T2V
Text-to-Video

{settings.promptEnhancerEnabledT2V ? 'Prompts will be enhanced before T2V generation' : 'T2V prompts used as-is'}

{/* I2V Toggle */}
handleTogglePromptEnhancer('i2v')} >
I2V
Image-to-Video

{settings.promptEnhancerEnabledI2V ? 'Prompts will be enhanced before I2V generation' : 'I2V prompts used as-is'}

)} {activeTab === 'about' && ( <> {showModelLicense ? (

LTX-2 Model License

                    {modelLicenseText}
                  
) : showNotices ? (

Third-Party Notices

                    {noticesText}
                  
) : (
{/* App Identity */}

LTX Desktop

Version {appVersion || '...'}

AI-Powered Video Editor

{/* Updates */}
Updates

{isMac ? ( <> {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} {update.state.status === 'downloaded' && ( autoCheckUpdates ? 'An update will install when you quit.' : 'This update is already queued and will still install when you quit.' )} {update.state.status === 'checking' && 'Checking for updates…'} {update.state.status === 'not-available' && "You're up to date."} {(update.state.status === 'idle' || update.state.status === 'available') && ( autoCheckUpdates ? 'New versions download in the background and install when you quit.' : 'Automatic updates are off. Turn this on to install new versions when you quit.' )} ) : ( <> {update.state.status === 'available' && `Version ${update.state.version} is available.`} {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} {update.state.status === 'downloaded' && 'Download complete. Restart to apply the update.'} {update.state.status === 'checking' && 'Checking for updates…'} {update.state.status === 'not-available' && "You're up to date."} {update.state.status === 'idle' && 'Check for a newer version, or let the app check automatically.'} )}

{update.state.message && (

{update.state.message}

)}

{isMac ? 'When on, new versions download in the background and install when you quit. Check above to look now.' : 'Periodically check for new versions. You can always check manually above.'}

{/* License */}
License

Licensed under the Apache License, Version 2.0

{/* LTX-2 Model License */}
LTX-2 Model License

The LTX-2 model is subject to the LTX-2 Community License Agreement, accepted during first-run setup.

{/* Third-Party Notices */}
Third-Party Notices

This application uses open-source software and AI models subject to their own license terms.

{/* Copyright */}

Copyright © 2026 Lightricks

)} )}
{/* Footer */}
) } export type { AppSettings, TabId as SettingsTabId }