import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' import { formatBytes } from '../lib/format' import { logger } from '../lib/logger' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' import { useAppSettings } from '../contexts/AppSettingsContext' import { HfModelAccessGate } from './HfModelAccessGate' import './FirstRunSetup.css' interface LaunchGateProps { licenseOnly?: boolean showLicenseStep?: boolean onComplete: () => Promise onAcceptLicense?: () => Promise } type Step = 'license' | 'location' | 'installing' | 'complete' type StartModelDownloadBody = NonNullable> type ModelCheckpointID = NonNullable[number] type LtxRecommendation = ApiSuccessOf<'getLtxRecommendation'> type ImgGenRecommendation = ApiSuccessOf<'getImgGenRecommendation'> type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'> type CheckpointDescriptor = ApiSuccessOf<'describeCheckpoints'>['checkpoints'][number] type DownloadStepSpec = { type: StartModelDownloadBody['type'] cpIds: ModelCheckpointID[] } // Fun loading messages const INSTALL_MESSAGES = [ "Downloading model weights...", "Teaching AI to dream in 4K...", "Loading neural pathways...", "Calibrating inference engine...", "Almost there...", "Unpacking the magic...", "Configuring parameters...", "Finalizing installation..." ] function uniqueCpIds(cpIds: readonly ModelCheckpointID[]): ModelCheckpointID[] { return [...new Set(cpIds)] } // User-facing explanation per checkpoint role (moved out of the backend so wording/i18n // iterates without a backend deploy). Keyed by the stable `role` the backend ships. const CP_INFO_BY_ROLE: Record = { base: 'The core LTX video model that turns your prompt into video frames. This is the largest download.', upscaler: 'Doubles the resolution of generated video for sharper, more detailed output. This updated version also ' + 'fixes glitches and stray text or overlay artifacts that could appear near the end of longer clips, and ' + 'keeps detail consistent through the final frames — recommended for long videos.', text_encoder: 'Reads your text prompt so the model understands it. You can skip this large download by entering an LTX ' + 'API key, which encodes prompts via the API instead.', vae: 'Decodes the model\'s latent frames into video (and audio, when present). Required for LTX versions that ' + 'ship the transformer separately from their VAEs.', image: 'Generates still images from text prompts (used for image-to-video and image tools).', support: 'A supporting model used for guided generation (depth, edges, or pose control).', } // One line in the first-run "What will be downloaded" list: checkpoint name, an // info-tooltip icon, and the size. Items an API key covers get a checkbox instead of being // silently dropped, so the download stays available to anyone who wants to run offline. function DownloadItem({ item, optIn, }: { item: CheckpointDescriptor optIn?: { checked: boolean; onToggle: () => void } }) { const skipped = optIn !== undefined && !optIn.checked return (
{optIn && ( )} {item.name} i {item.downloaded ? 'Installed' : skipped ? 'Skipped (API key)' : formatBytes(item.size_bytes)}
) } function buildDownloadSteps( ltxRecommendation: LtxRecommendation, imgGenRecommendation: ImgGenRecommendation, extraCpIds: readonly ModelCheckpointID[] = [], ): DownloadStepSpec[] { const cpIds: ModelCheckpointID[] = [] if (ltxRecommendation.status === 'download') { cpIds.push(...ltxRecommendation.cps_to_download) } if (imgGenRecommendation.cp_to_download) { cpIds.push(imgGenRecommendation.cp_to_download) } cpIds.push(...extraCpIds) const unique = uniqueCpIds(cpIds) return unique.length > 0 ? [{ type: 'download', cpIds: unique }] : [] } export function LaunchGate({ licenseOnly, showLicenseStep = true, onComplete, onAcceptLicense, }: LaunchGateProps) { const [currentStep, setCurrentStep] = useState(showLicenseStep ? 'license' : 'location') const [installPath, setInstallPath] = useState('') const [downloadProgress, setDownloadProgress] = useState(null) const [downloadError, setDownloadError] = useState(null) const [downloadSessionId, setDownloadSessionId] = useState(null) const [installMessage, setInstallMessage] = useState(INSTALL_MESSAGES[0]) const [availableSpace, setAvailableSpace] = useState('...') const [downloadItems, setDownloadItems] = useState([]) const [optionalItems, setOptionalItems] = useState([]) const [optedInCpIds, setOptedInCpIds] = useState([]) const [videoPath, setVideoPath] = useState('/splash/splash.mp4') const [ltxApiKey, setLtxApiKey] = useState('') const [hasSavedLtxApiKey, setHasSavedLtxApiKey] = useState(false) const [licenseAccepted, setLicenseAccepted] = useState(false) const [licenseText, setLicenseText] = useState(null) const [licenseError, setLicenseError] = useState(null) const [actionError, setActionError] = useState(null) const [isActionPending, setIsActionPending] = useState(false) const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(currentStep === 'location') // A key typed here isn't saved yet, so the backend still lists the text encoder as required; // move it to the opt-in list so the choice reads the same either way. const keyEnteredNow = ltxApiKey.trim().length > 0 const requiredItems = useMemo( () => downloadItems.filter((item) => !(item.role === 'text_encoder' && keyEnteredNow)), [downloadItems, keyEnteredNow], ) const allOptionalItems = useMemo( () => [ ...(keyEnteredNow ? downloadItems.filter((item) => item.role === 'text_encoder') : []), ...optionalItems, ], [downloadItems, keyEnteredNow, optionalItems], ) const isOptedIn = (item: CheckpointDescriptor) => optedInCpIds.includes(item.cp_id as ModelCheckpointID) const toggleOptIn = (item: CheckpointDescriptor) => { const cpId = item.cp_id as ModelCheckpointID setOptedInCpIds((ids) => (ids.includes(cpId) ? ids.filter((id) => id !== cpId) : [...ids, cpId])) } const pendingDownloadItems = useMemo( () => [...requiredItems, ...allOptionalItems.filter((item) => optedInCpIds.includes(item.cp_id as ModelCheckpointID))] .filter((item) => !item.downloaded), [requiredItems, allOptionalItems, optedInCpIds], ) const installCpIds = useMemo( () => uniqueCpIds(pendingDownloadItems.map((item) => item.cp_id as ModelCheckpointID)), [pendingDownloadItems], ) const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess( currentStep === 'location' ? installCpIds : [], hfAuthStatus, ) const { saveLtxApiKey } = useAppSettings() const downloadQueueRef = useRef([]) const runningDownloadProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null const totalProgress = runningDownloadProgress?.total_progress ?? (downloadProgress?.status === 'complete' ? 100 : 0) const totalDownloadBytes = pendingDownloadItems.reduce((sum, item) => sum + item.size_bytes, 0) // Format time remaining 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` } // Calculate ETA based on speed and remaining bytes const getTimeRemaining = (): string => { if (!runningDownloadProgress || runningDownloadProgress.speed_bytes_per_sec <= 0) return '--' const remainingBytes = runningDownloadProgress.expected_total_bytes - runningDownloadProgress.total_downloaded_bytes if (remainingBytes <= 0) return '--' const secondsRemaining = remainingBytes / runningDownloadProgress.speed_bytes_per_sec return formatTimeRemaining(secondsRemaining) } // Fetch license text const fetchLicense = async () => { setLicenseError(null) setLicenseText(null) try { const text = await window.electronAPI.fetchLicenseText() setLicenseText(text) } catch (e) { setLicenseError(e instanceof Error ? e.message : 'Failed to fetch license text.') } } const refreshModelRecommendations = useCallback(async () => { if (licenseOnly) return const [settingsResult, ltxResult, imgGenResult] = await Promise.all([ ApiClient.getSettings(), ApiClient.getLtxRecommendation(), ApiClient.getImgGenRecommendation(), ]) if (!settingsResult.ok) { logger.error(`Failed to fetch model recommendations: ${settingsResult.error.message}`) return } if (!ltxResult.ok) { logger.error(`Failed to fetch model recommendations: ${ltxResult.error.message}`) return } if (!imgGenResult.ok) { logger.error(`Failed to fetch model recommendations: ${imgGenResult.error.message}`) return } setInstallPath(settingsResult.data.modelsDir ?? '') setHasSavedLtxApiKey(Boolean(settingsResult.data.hasLtxApiKey)) // Surface exactly what the install will download (base model, upscaler, text // encoder, image model) with per-checkpoint sizes and info. Same cp set the // installer actually downloads, so the preview can't drift from reality. const cpIds = buildDownloadSteps(ltxResult.data, imgGenResult.data).flatMap((step) => step.cpIds) const optionalCpIds = ltxResult.data.status === 'download' ? ltxResult.data.optional_cp_ids : [] if (cpIds.length === 0 && optionalCpIds.length === 0) { setDownloadItems([]) setOptionalItems([]) return } const describeResult = await ApiClient.describeCheckpoints({ cp_ids: [...cpIds, ...optionalCpIds] }) if (!describeResult.ok) { logger.error(`Failed to describe checkpoints: ${describeResult.error.message}`) return } const described = describeResult.data.checkpoints const isOptional = (item: CheckpointDescriptor) => optionalCpIds.includes(item.cp_id) setDownloadItems(described.filter((item) => !isOptional(item))) setOptionalItems(described.filter(isOptional)) }, [licenseOnly]) const startDownloadStep = useCallback(async (step: DownloadStepSpec) => { setDownloadProgress(null) setDownloadError(null) const result = await ApiClient.startModelDownload({ type: step.type, cp_ids: step.cpIds, }) if (!result.ok) { throw new Error(result.error.message) } const downloadData = result.data if (downloadData.status === 'started') { setDownloadSessionId(downloadData.sessionId) return } throw new Error('Unexpected response while starting model download.') }, []) // Initialize useEffect(() => { const init = async () => { try { // Get video path for production (unpacked from asar) 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 setVideoPath('/splash/splash.mp4') } await refreshModelRecommendations() } catch (e) { logger.error(`Init error: ${e}`) } } init() if (showLicenseStep) { void fetchLicense() } }, [refreshModelRecommendations, showLicenseStep]) useEffect(() => { if (!installPath) { setAvailableSpace('...') return } let cancelled = false void (async () => { try { const result = await window.electronAPI.getFreeDiskSpace({ path: installPath }) if (cancelled) return setAvailableSpace(result.success ? formatBytes(result.bytes) : '—') } catch { if (!cancelled) setAvailableSpace('—') } })() return () => { cancelled = true } }, [installPath]) // Cycle install messages useEffect(() => { if (currentStep !== 'installing') return let index = 0 const interval = setInterval(() => { index = (index + 1) % INSTALL_MESSAGES.length setInstallMessage(INSTALL_MESSAGES[index]) }, 4000) return () => clearInterval(interval) }, [currentStep]) // Poll download progress during installation useEffect(() => { if (currentStep !== 'installing' || !downloadSessionId) return const pollProgress = async () => { const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId }) if (!result.ok) { logger.error(`Progress poll error: ${result.error.message}`) return } const progress = result.data setDownloadProgress(progress) if (progress.status === 'error') { downloadQueueRef.current = [] setDownloadError(progress.error || 'Download failed.') } else if (progress.status === 'complete') { const nextStep = downloadQueueRef.current.shift() ?? null if (nextStep) { await startDownloadStep(nextStep) return } setTimeout(() => setCurrentStep('complete'), 600) } } pollProgress() const interval = setInterval(pollProgress, 500) return () => clearInterval(interval) }, [currentStep, downloadSessionId, startDownloadStep]) // Start installation const startInstallation = async () => { setCurrentStep('installing') try { if (ltxApiKey.trim()) { try { await saveLtxApiKey(ltxApiKey.trim()) } catch (e) { logger.error(`Failed to save API key: ${e instanceof Error ? e.message : String(e)}`) } } const [ltxResult, imgGenResult] = await Promise.all([ ApiClient.getLtxRecommendation(), ApiClient.getImgGenRecommendation(), ]) if (!ltxResult.ok) { throw new Error(ltxResult.error.message) } if (!imgGenResult.ok) { throw new Error(imgGenResult.error.message) } const nextLtxRecommendation = ltxResult.data const nextImgGenRecommendation = imgGenResult.data // Saving the key above drops the encoder from the recommendation, so an explicit opt-in // has to be added back here rather than read off the fresh response. const downloadSteps = buildDownloadSteps( nextLtxRecommendation, nextImgGenRecommendation, optedInCpIds, ) if (downloadSteps.length === 0) { setCurrentStep('complete') return } downloadQueueRef.current = downloadSteps.slice(1) await startDownloadStep(downloadSteps[0]) } catch (e) { logger.error(`Download start error: ${e}`) setDownloadError(e instanceof Error ? e.message : 'Failed to start model download.') } } const retryInstallation = () => { setDownloadError(null) downloadQueueRef.current = [] startInstallation() } // Handle next button const handleNext = async () => { setActionError(null) if (currentStep === 'license') { if (!licenseAccepted) return setIsActionPending(true) try { if (onAcceptLicense) { await onAcceptLicense() } if (licenseOnly) { await onComplete() return } setCurrentStep('location') } catch (e) { setActionError(e instanceof Error ? e.message : 'Failed to accept license.') } finally { setIsActionPending(false) } return } if (currentStep === 'location') { startInstallation() return } if (currentStep === 'complete') { await handleFinish() } } const handleFinish = async () => { setActionError(null) setIsActionPending(true) try { await onComplete() } catch (e) { setActionError(e instanceof Error ? e.message : 'Failed to complete setup.') } finally { setIsActionPending(false) } } // Get button text const getNextButtonText = () => { if (currentStep === 'license') return licenseOnly ? 'Accept' : 'Next' if (currentStep === 'location') return 'Install' if (currentStep === 'complete') return 'Finish' return 'Continue' } // Check if next button should be disabled const isNextDisabled = () => { if (currentStep === 'license') return !licenseAccepted || isActionPending if (currentStep === 'location') { return installCpIds.length > 0 && (!allAuthorized || checkingAccess) } if (currentStep === 'complete') return isActionPending return false } return (
{/* Custom Title Bar */}
{/* Main Container */}
{/* Header */}
{/* LTX Logo */} Desktop
{/* Content Area */}
{/* Step 1: Model License */} {currentStep === 'license' && (

LTX-2 Model License

The LTX-2 model is subject to the following license agreement. Please review and accept before downloading.

{licenseError ? (
{licenseError}
) : licenseText === null ? (
Loading license...
) : (
                        {licenseText?.replace(/([^\n])\n([^\n])/g, '$1 $2')}
                      
)}
)} {/* Step 2: Choose Location */} {currentStep === 'location' && (

Choose Location

Select where to install the model files.

Available: {availableSpace}
{/* What will be downloaded */} {(requiredItems.length > 0 || allOptionalItems.length > 0) && (
Total: {formatBytes(totalDownloadBytes)}
{requiredItems.map((item) => ( ))}
{allOptionalItems.length > 0 && (

{allOptionalItems.every((item) => item.role === 'text_encoder') ? "Your LTX API key covers this, so it isn't needed to generate. Download it to encode prompts on this computer instead — slower, but works offline and without a key." : "Not required for the current setup. Tick any you want to keep on this computer."}

{allOptionalItems.map((item) => ( toggleOptIn(item) }} /> ))}
)}
)} {/* LTX API Key - Optional but saves ~25 GB download */}
setLtxApiKey(e.target.value)} placeholder="Enter API key to make the text encoder optional..." style={{ width: '100%', background: '#1a1a1a', border: '1px solid #333', borderRadius: 8, padding: '12px 14px', color: '#ffffff', fontSize: 13, boxSizing: 'border-box' }} />

{ltxApiKey || hasSavedLtxApiKey ? ( ✓ Text encoder download is optional (using API instead). Tick it under Optional if you want it offline. ) : ( 'If you have an LTX API key, entering it here makes the text encoder optional. ' + 'The API encodes prompts faster than running the local encoder.' )}

{/* HuggingFace Authentication */}
{allAuthorized ? ( hfAuthStatus === 'authenticated' ? (

✓ Authenticated — gated models will download with your account.

) : ( <>

Optional for this install. Sign in if you later download gated models from Settings.

) ) : ( { void startHuggingFaceLogin() }} checkError={checkError} onRetryCheck={recheckAccess} /> )}
)} {/* Step 3: Installing */} {currentStep === 'installing' && (
{/* Video Section - fills container but leaves room for progress */}
{/* Splash Video */} {/* Video Credit */}
Generated by PongFlongo
{/* Progress Section - fixed at bottom */}
{downloadError ? (
{downloadError}
) : ( <> {/* Header row with status and percentage */}
{totalProgress > 85 ? 'Installing...' : 'Downloading...'} {Math.round(totalProgress)}%
{/* Progress Bar */}
{/* Download stats row */}
{/* Current file */} {runningDownloadProgress?.current_downloading_file || installMessage} {/* Speed and ETA */}
{runningDownloadProgress && runningDownloadProgress.speed_bytes_per_sec > 0 && ( {(runningDownloadProgress.speed_bytes_per_sec / (1024 * 1024)).toFixed(1)} MB/s )} {runningDownloadProgress && runningDownloadProgress.expected_total_bytes > 0 && ( {formatBytes(runningDownloadProgress.total_downloaded_bytes)} / {formatBytes(runningDownloadProgress.expected_total_bytes)} )} {runningDownloadProgress && runningDownloadProgress.speed_bytes_per_sec > 0 && ( ETA: {getTimeRemaining()} )}
{/* Files progress */} {runningDownloadProgress && runningDownloadProgress.all_files.length > 0 && (
File {runningDownloadProgress.completed_files.length + 1} of {runningDownloadProgress.all_files.length}
)} )}
)} {/* Step 4: Complete */} {currentStep === 'complete' && (
{/* Success Icon */}

Ready to Create

LTX Video is installed. Start generating.

{/* Install Summary */}
Location {installPath.split('\\').pop() || installPath}
)}
{/* Footer */}
© 2026 Lightricks
{/* Next/Install/Finish Button */} {currentStep !== 'installing' && ( )}
{actionError && (
{actionError}
)}
) }