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
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react'
import type { UpdateStatePayload } from '../../shared/electron-api-schema'
import { useIsGenerationActive } from '../lib/generation-active'
export type AppUpdate = {
state: UpdateStatePayload
checkForUpdates: () => Promise<void>
startDownload: () => Promise<void>
installAndRestart: () => Promise<{ success: true } | { success: false; error: string }>
skipVersion: (version: string) => Promise<void>
}
const INITIAL: UpdateStatePayload = { status: 'idle', currentVersion: '' }
const MODAL_STATUSES: ReadonlySet<UpdateStatePayload['status']> = new Set([
'available',
'downloading',
'downloaded',
])
export function useAppUpdate(): AppUpdate {
const [state, setState] = useState<UpdateStatePayload>(INITIAL)
useEffect(() => {
let alive = true
let fromEvent = false
// Subscribe first so a check that starts during getUpdateState cannot be missed,
// then ignore the snapshot if an event already applied a newer value.
const unsubscribe = window.electronAPI.onUpdateEvent((data) => {
fromEvent = true
if (alive) setState(data)
})
void window.electronAPI.getUpdateState()
.then((s) => { if (alive && !fromEvent) setState(s) })
.catch(() => {})
return () => { alive = false; unsubscribe() }
}, [])
const checkForUpdates = useCallback(async () => {
await window.electronAPI.checkForUpdatesNow()
}, [])
const startDownload = useCallback(async () => {
await window.electronAPI.startUpdateDownload()
}, [])
const installAndRestart = useCallback(async () => {
return window.electronAPI.installUpdateAndRestart()
}, [])
const skipVersion = useCallback(async (version: string) => {
await window.electronAPI.skipUpdateVersion({ version })
}, [])
return { state, checkForUpdates, startDownload, installAndRestart, skipVersion }
}
/** Session Later / skip / manual-check intent. App only mounts the modal. */
export function useAppUpdateModal() {
const update = useAppUpdate()
const isGenerationActive = useIsGenerationActive()
const [modalOpen, setModalOpen] = useState(false)
const [manualCheckPending, setManualCheckPending] = useState(false)
const [laterVersion, setLaterVersion] = useState<string | null>(null)
useEffect(() => {
// Mac has no modal: silent download + install-on-quit, gated only by the About toggle.
if (window.electronAPI.platform === 'darwin') return
const s = update.state
if (s.status === 'available') {
if (manualCheckPending || s.version !== laterVersion) {
setModalOpen(true)
}
if (manualCheckPending) setManualCheckPending(false)
} else if (s.status === 'downloaded') {
// Bits are on disk: the modal is the only install path and must stay up,
// including after Hide-during-download.
setModalOpen(true)
} else if (s.status === 'not-available') {
if (manualCheckPending) setManualCheckPending(false)
}
}, [update.state.status, update.state.version, manualCheckPending, laterVersion])
const requestCheck = update.checkForUpdates
const skipVersion = update.skipVersion
const version = update.state.version
const status = update.state.status
const checkForUpdates = useCallback(() => {
setManualCheckPending(true)
void requestCheck()
}, [requestCheck])
const closeModal = useCallback((skipThisVersion: boolean) => {
if (status === 'downloaded') return
if (skipThisVersion && version) void skipVersion(version)
// Hide during download is not Later — keep the session prompt so a failed
// download can reopen the modal. Later/skip only apply when dismissing the offer.
else if (version && status !== 'downloading') setLaterVersion(version)
setModalOpen(false)
}, [skipVersion, version, status])
const openModal = useCallback(() => setModalOpen(true), [])
return {
update,
isGenerationActive,
// Keep the modal mounted across a periodic re-check (`checking`) so it does not
// unmount/remount. Do not treat `available` as busy in main — that would hide a
// newer version after the user clicked Later.
isModalOpen:
window.electronAPI.platform !== 'darwin'
&& modalOpen
&& (MODAL_STATUSES.has(status) || status === 'checking'),
openModal,
closeModal,
checkForUpdates,
}
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react'
import { resetBackendCredentials } from '../lib/backend'
import { logger } from '../lib/logger'
export type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
interface BackendHealthStatusPayload {
status: BackendProcessStatus
exitCode?: number | null
}
interface UseBackendReturn {
processStatus: BackendProcessStatus | null
connected: boolean
isLoading: boolean
}
function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as { status?: unknown; exitCode?: unknown }
if (record.status !== 'alive' && record.status !== 'restarting' && record.status !== 'dead') {
return null
}
return {
status: record.status,
exitCode: typeof record.exitCode === 'number' || record.exitCode === null ? record.exitCode : undefined,
}
}
export function useBackend(): UseBackendReturn {
const [processStatus, setProcessStatus] = useState<BackendProcessStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const handleBackendStatus = useCallback((payload: BackendHealthStatusPayload) => {
setProcessStatus(payload.status)
if (payload.status === 'alive') {
// Main has verified HTTP reachability before publishing 'alive' and may
// have spawned a fresh backend with a new port/token — drop cached creds
// so the next backendFetch picks up the current values.
resetBackendCredentials()
setIsLoading(false)
return
}
if (payload.status === 'restarting') {
return
}
setIsLoading(false)
}, [])
useEffect(() => {
let cancelled = false
const applyStatus = (value: unknown) => {
const payload = toBackendHealthStatus(value)
if (!payload || cancelled) {
return
}
handleBackendStatus(payload)
}
const unsubscribe = window.electronAPI.onBackendHealthStatus((data: BackendHealthStatusPayload) => {
applyStatus(data)
})
const init = async () => {
try {
const snapshot = await window.electronAPI.getBackendHealthStatus()
applyStatus(snapshot)
} catch (err) {
logger.error(`Failed to load backend health status snapshot: ${err}`)
}
}
void init()
return () => {
cancelled = true
unsubscribe()
}
}, [handleBackendStatus])
return {
processStatus,
connected: processStatus === 'alive',
isLoading,
}
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import type { ApiSuccessOf } from '../lib/api-client'
import { catalogVariantKey } from '../lib/lora-library'
export type IcLoraListItem = ApiSuccessOf<'listIcLoras'>['ic_loras'][number]
export type LoraCatalogListItem = ApiSuccessOf<'listLoras'>['loras'][number]
type StartResult = { ok: true; data: { sessionId: string } } | { ok: false; error: { message: string } }
type ProgressResult =
| { ok: true; data: { progress: number; status: 'downloading' | 'complete' | 'error'; error?: string | null } }
| { ok: false; error: { message: string } }
// Shared download + poll state machine for a catalog item (LoRA or IC-LoRA). The two hooks
// below differ only in which endpoints they hit and how they list items; this owns the
// identical download/poll/error half. onComplete refreshes the caller's list.
// downloadingKey / downloadError.key are catalogVariantKey(id, variantId) so two checkpoints
// of the same item don't clobber each other's UI state.
function useCatalogDownload(
startDownload: (id: string, variantId?: string) => Promise<StartResult>,
getProgress: (sessionId: string) => Promise<ProgressResult>,
onComplete: () => void,
) {
const [downloadingKey, setDownloadingKey] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [downloadError, setDownloadError] = useState<{ key: string; message: string } | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const download = useCallback(async (id: string, variantId?: string) => {
const key = catalogVariantKey(id, variantId)
setDownloadError(null)
const start = await startDownload(id, variantId)
if (!start.ok) {
setDownloadError({ key, message: start.error.message })
return
}
setDownloadingKey(key)
setProgress(0)
const sessionId = start.data.sessionId
// Never stack intervals: a re-entrant download() would otherwise leak the previous one.
if (pollRef.current) clearInterval(pollRef.current)
const stop = () => {
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = null
setDownloadingKey(null)
setProgress(0)
}
let inFlight = false
let failures = 0
pollRef.current = setInterval(async () => {
if (inFlight) return // don't overlap polls if a tick outruns the 1s interval
inFlight = true
try {
const p = await getProgress(sessionId)
if (!p.ok) {
// Tolerate a blip, but give up (and surface it) rather than poll forever.
if (++failures >= 3) { stop(); setDownloadError({ key, message: 'Lost contact with the download.' }) }
return
}
failures = 0
setProgress(p.data.progress)
if (p.data.status !== 'downloading') {
stop()
if (p.data.status === 'error') setDownloadError({ key, message: p.data.error ?? 'Download failed' })
onComplete()
}
} finally {
inFlight = false
}
}, 1000)
}, [startDownload, getProgress, onComplete])
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
return { downloadingKey, progress, downloadError, download }
}
export function useIcLoras(enabled: boolean) {
const [icLoras, setIcLoras] = useState<IcLoraListItem[]>([])
const refresh = useCallback(async () => {
if (!enabled) return
const r = await ApiClient.listIcLoras()
if (r.ok) setIcLoras(r.data.ic_loras)
}, [enabled])
useEffect(() => { void refresh() }, [refresh])
const { downloadingKey, progress, downloadError, download: downloadIcLora } = useCatalogDownload(
// Attach the in-app HF token when the user is signed in; the backend ignores it for public
// repos and requires it only for gated entries (optional auth).
(id, variantId) => ApiClient.startIcLoraDownload({
ic_lora_id: id,
variant_id: variantId,
use_hf_auth: true,
}),
(sessionId) => ApiClient.getIcLoraDownloadProgress({ sessionId }),
refresh,
)
return { icLoras, refresh, downloadIcLora, downloadingKey, progress, downloadError }
}
// Plain-LoRA catalog: uses /api/loras* (an isolated download session, so it never collides
// with an in-flight IC-LoRA download).
export function useLoraCatalog(enabled: boolean) {
const [loras, setLoras] = useState<LoraCatalogListItem[]>([])
const refresh = useCallback(async () => {
if (!enabled) return
const r = await ApiClient.listLoras()
if (r.ok) setLoras(r.data.loras)
}, [enabled])
useEffect(() => { void refresh() }, [refresh])
const { downloadingKey, progress, downloadError, download: downloadLora } = useCatalogDownload(
(id, variantId) => ApiClient.startLoraDownload({
lora_id: id,
variant_id: variantId,
use_hf_auth: true,
}),
(sessionId) => ApiClient.getLoraDownloadProgress({ sessionId }),
refresh,
)
return { loras, refresh, downloadLora, downloadingKey, progress, downloadError }
}
@@ -0,0 +1,9 @@
import { useDevFlags } from '../contexts/DevFlagsContext'
// Single source of truth for whether the "Custom IC-LoRA" UI is available.
// Off by default via the dev flag. Built-in Canny/Depth IC-LoRA is unaffected.
// The custom option only appears inside IC-LoRA mode, which is already gated by
// the existing API-only check — so this flag is all the extra gating it needs.
export function useCustomIcLoraEnabled(): boolean {
return useDevFlags().flags.customIcLora
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
import { prepareGenerationInput } from '../lib/generation-input'
import { useAppSettings } from '../contexts/AppSettingsContext'
import type { RetakeExtendModel } from './use-retake'
export type ExtendDirection = 'start' | 'end'
// Seconds-to-add presets (matches LTX Studio). API allows 2–20s.
export const EXTEND_SECONDS = [4, 6, 8, 10, 12] as const
export const DEFAULT_EXTEND_SECONDS = 4
export interface ExtendSubmitParams {
videoPath: string
duration: number
prompt: string
mode: ExtendDirection
resolution?: { width: number; height: number }
model: RetakeExtendModel
}
export interface ExtendResult {
videoPath: string
}
interface UseExtendState {
isExtending: boolean
canCancel: boolean
extendStatus: string
extendError: string | null
result: ExtendResult | null
}
export function useExtend() {
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
const [state, setState] = useState<UseExtendState>({
isExtending: false,
canCancel: false,
extendStatus: '',
extendError: null,
result: null,
})
const submitExtend = useCallback(async (params: ExtendSubmitParams) => {
if (!params.videoPath) return
setState({
isExtending: true,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
extendStatus: 'Generating',
extendError: null,
result: null,
})
await withGenerationActive(async () => {
let preparedVideoPath: string
try {
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
logger.error(`Extend input error: ${message}`)
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: message, result: null })
return
}
const result = await ApiClient.extend({
video_path: preparedVideoPath,
duration: params.duration,
prompt: params.prompt,
mode: params.mode,
resolution: params.resolution,
model: params.model,
})
if (!result.ok) {
logger.error(`Extend error: ${result.error.message}`)
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: result.error.message, result: null })
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({ isExtending: false, canCancel: false, extendStatus: 'Cancelled', extendError: null, result: null })
return
}
if ('video_path' in payload) {
setState({
isExtending: false,
canCancel: false,
extendStatus: 'Extend complete!',
extendError: null,
result: { videoPath: payload.video_path },
})
return
}
// A 200 with a remote payload and no local file is a legitimate success (the backend
// completed the generation); there's just no local artifact to import. Don't surface
// it as an error.
logger.warn(`Extend completed with a remote payload and no local file: ${JSON.stringify(payload.result)}`)
setState({
isExtending: false,
canCancel: false,
extendStatus: 'Extend complete!',
extendError: null,
result: null,
})
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const resetExtend = useCallback(() => {
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: null, result: null })
}, [])
return {
submitExtend,
resetExtend,
isExtending: state.isExtending,
canCancel: state.canCancel,
extendStatus: state.extendStatus,
extendError: state.extendError,
extendResult: state.result,
}
}
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { fixedMenuPosition, type MenuPlacement } from '../lib/fixed-menu-position'
const HIDDEN_FIXED: CSSProperties = { position: 'fixed', zIndex: 9999, visibility: 'hidden' }
export function useFixedMenu(placement: MenuPlacement) {
const [isOpen, setIsOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [style, setStyle] = useState<CSSProperties>(HIDDEN_FIXED)
const updatePosition = useCallback(() => {
const trigger = triggerRef.current?.getBoundingClientRect()
if (!trigger) return
const menuWidth = menuRef.current?.offsetWidth
const pos = fixedMenuPosition({
trigger,
placement,
viewport: { width: window.innerWidth, height: window.innerHeight },
menuWidth: menuWidth || undefined,
})
setStyle({
position: 'fixed',
zIndex: 9999,
visibility: 'visible',
left: pos.left,
...(pos.top != null ? { top: pos.top } : { bottom: pos.bottom }),
})
}, [placement])
useLayoutEffect(() => {
if (!isOpen) {
setStyle(HIDDEN_FIXED)
return
}
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [isOpen, updatePosition])
useEffect(() => {
if (!isOpen) return
const onPointerDown = (event: MouseEvent) => {
const node = event.target as Node
if (triggerRef.current?.contains(node) || menuRef.current?.contains(node)) return
setIsOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [isOpen])
return { isOpen, setIsOpen, triggerRef, menuRef, style }
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react'
import { useProjects } from '../contexts/ProjectContext'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { checkAndConsumeRecovery } from '../lib/generation-recovery'
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
// Always-mounted safety net for a generation that finishes while its own project's GenSpace
// isn't open (a different project, the Video Editor tab, or Home): periodically checks whether
// the pending recovery marker's generation has completed and, if so, persists it directly into
// that project's assets — independent of whichever view/project is currently on screen. Mount
// this once, near the app root (outside whatever view-switching renders GenSpace), so it survives
// navigation. GenSpace's own live polling/import effects are unchanged and take priority — see
// setActiveGenerationOwner in lib/generation-recovery.ts for how the two avoid double-importing.
export function useGenerationRecoveryWatcher(): void {
const { addAsset } = useProjects()
const { settings } = useAppSettings()
const modelsDirRef = useRef(settings.modelsDir)
modelsDirRef.current = settings.modelsDir
const isCheckingRef = useRef(false)
useEffect(() => subscribeWhileGenerationMayBeActive(progress => {
if (isCheckingRef.current) return
isCheckingRef.current = true
void checkAndConsumeRecovery(progress, { addAsset, modelsDir: modelsDirRef.current })
.finally(() => { isCheckingRef.current = false })
}), [addAsset])
}
+601
View File
@@ -0,0 +1,601 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import type { GenerationSettings } from '../components/SettingsPanel'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { buildGenerateVideoImageInputs } from '../lib/build-generate-video-body'
import type { GenSpaceMode } from '../lib/genspace-multi-keyframe'
import type { KeyframeItem, PersistedKeyframe } from '../lib/multi-keyframe'
import { prepareGenerationInput } from '../lib/generation-input'
const POLLING_INTERVAL_MS = 2000
export const GENERATION_RECOVERY_KEY = 'ltx-generation-recovery'
export interface GenerationRecoveryContext {
projectId: string
prompt: string
// Absent for ic-lora/retake: those recover as standalone video assets (Phase 1),
// so there are no video/image settings to restore.
settings?: GenerationSettings
// Retake/extend write this instead of a full `settings` blob — the recovery importer
// prefers it over `settings.model` (which defaults to 'fast' when absent).
model?: string
// Frozen at click. Local `fast` and API `fast` share an id; display_name must not be
// re-resolved from whichever offering is selected when the job later finishes.
modelLabel?: string
inputImageUrl?: string
inputLastImageUrl?: string
inputAudioUrl?: string
keyframes?: PersistedKeyframe[]
genType?: 'image' | 'enhance'
// Frozen at marker write (job start) — same rule as hook canCancel. Lets Stop survive a
// UI refresh before the first progress poll returns (local GPU can starve that poll).
// Absent on older markers: treat as not cancellable until the poll reports it.
canCancel?: boolean
// Whatever generation id the backend reported at the moment this marker was written — i.e.
// immediately BEFORE this generation started. The handler that starts a generation loads its
// pipeline (can take many seconds — worse for image models loading checkpoint shards) before
// it ever reports a new id, so a poll can otherwise be looking at a stale, unrelated id/result
// that predates this marker entirely. Once a later poll observes a DIFFERENT id, that's proof
// (single global generation slot) that this marker's own generation has started — see
// checkAndConsumeRecovery in lib/generation-recovery.ts.
baselineId: string | null
// Set once a poll observes an id different from baselineId — i.e. once this marker's own
// generation is confirmed to exist. Distinct from baselineId: a LATER id change past this point
// means a DIFFERENT generation superseded ours (not that ours just started), which must NOT be
// imported under this marker.
generationId?: string
}
export function readRecoveryMarkerCanCancel(): boolean {
const saved = localStorage.getItem(GENERATION_RECOVERY_KEY)
if (!saved) return false
try {
return (JSON.parse(saved) as GenerationRecoveryContext).canCancel === true
} catch {
return false
}
}
interface GenerationState {
isGenerating: boolean
isCancelling: boolean
/** Frozen at job start — Stop stays hidden if this POST was an LTX/FAL cloud job. */
canCancel: boolean
progress: number
statusMessage: string
videoPath: string | null
imagePath: string | null
imagePaths: string[]
error: GenerationError | null
}
type GenerateVideoRequest = ApiRequestBodyOf<'generateVideo'>
type GenerateImageRequest = ApiRequestBodyOf<'generateImage'>
interface UseGenerationReturn extends GenerationState {
generate: (
prompt: string,
imagePath: string | null,
settings: GenerationSettings,
audioPath?: string | null,
lastImagePath?: string | null,
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
) => Promise<void>
generateImage: (prompt: string, settings: GenerationSettings, editSource?: string | null) => Promise<void>
cancel: () => void
reset: () => void
resumeIfRunning: () => Promise<'running' | 'complete' | 'none'>
}
const IMAGE_SHORT_SIDE_BY_RESOLUTION: Record<string, number> = {
'1080p': 1080,
'1440p': 1440,
'2048p': 2048,
}
const IMAGE_ASPECT_RATIO_VALUE: Record<string, number> = {
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
'4:3': 4 / 3,
'3:4': 3 / 4,
'21:9': 21 / 9,
}
function getImageDimensions(settings: GenerationSettings): { width: number; height: number } {
const shortSide = IMAGE_SHORT_SIDE_BY_RESOLUTION[settings.imageResolution]
if (!shortSide) {
throw new Error(`Unsupported image resolution mapping: ${settings.imageResolution}`)
}
const ratio = IMAGE_ASPECT_RATIO_VALUE[settings.imageAspectRatio]
if (!ratio) {
throw new Error(`Unsupported image aspect ratio mapping: ${settings.imageAspectRatio}`)
}
if (ratio >= 1) {
return { width: Math.round(shortSide * ratio), height: shortSide }
}
return { width: shortSide, height: Math.round(shortSide / ratio) }
}
// Map phase to user-friendly message
function getPhaseMessage(phase: string): string {
switch (phase) {
case 'validating_request':
return 'Validating request...'
case 'uploading_image':
return 'Uploading image...'
case 'uploading_audio':
return 'Uploading audio...'
case 'loading_model':
return 'Loading model...'
case 'encoding_text':
return 'Encoding prompt...'
case 'inference':
return 'Generating...'
case 'downloading_output':
return 'Downloading output...'
case 'decoding':
return 'Decoding video...'
case 'complete':
return 'Complete!'
case 'cancelled':
return 'Cancelling…'
default:
return 'Generating...'
}
}
export function useGeneration(): UseGenerationReturn {
const { settings: appSettings, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings } = useAppSettings()
const [state, setState] = useState<GenerationState>({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 0,
statusMessage: '',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
const recoveryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const clearRecoveryPolling = () => {
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current)
recoveryIntervalRef.current = null
}
}
useEffect(() => clearRecoveryPolling, [])
// Re-attach to a generation that was running OR finished while the frontend was
// unmounted. Polls the backend progress endpoint; localStorage recovery context
// (inputs, settings incl. loras) is owned by the caller (GenSpace). Returns the
// recovered status so the caller can restore context for 'running' AND 'complete'
// (a generation that finished during the unmount window still needs its metadata).
const resumeIfRunning = useCallback(async (): Promise<'running' | 'complete' | 'none'> => {
const apply = (data: ApiSuccessOf<'getGenerationProgress'>): 'running' | 'complete' | 'other' => {
if (data.status === 'complete' && data.result != null) {
const vp = typeof data.result === 'string' ? data.result : null
const ips = Array.isArray(data.result) ? data.result : []
setState({
isGenerating: false, isCancelling: false, canCancel: false, progress: 100, statusMessage: 'Complete!',
videoPath: vp, imagePath: ips[0] ?? null, imagePaths: ips, error: null,
})
return 'complete'
}
if (data.status === 'running') {
setState(prev => ({
...prev,
isGenerating: true,
isCancelling: data.phase === 'cancelled',
canCancel: data.cancellable,
progress: data.progress,
statusMessage: getPhaseMessage(data.phase),
}))
return 'running'
}
setState(prev => ({ ...prev, isGenerating: false, isCancelling: false, canCancel: false, statusMessage: '' }))
return 'other'
}
const initial = await ApiClient.getGenerationProgress()
if (!initial.ok) return 'none'
const status = apply(initial.data)
if (status === 'complete') return 'complete'
if (status !== 'running') return 'none'
clearRecoveryPolling()
recoveryIntervalRef.current = setInterval(async () => {
const r = await ApiClient.getGenerationProgress()
if (!r.ok) return
if (apply(r.data) !== 'running') clearRecoveryPolling()
}, POLLING_INTERVAL_MS)
return 'running'
}, [])
const generate = useCallback(async (
prompt: string,
imagePath: string | null,
settings: GenerationSettings,
audioPath?: string | null,
lastImagePath?: string | null,
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
) => {
const statusMsg = settings.model.startsWith('pro')
? 'Loading Pro model & generating...'
: 'Generating video...'
setState({
isGenerating: true,
isCancelling: false,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
progress: 0,
statusMessage: statusMsg,
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
let progressInterval: ReturnType<typeof setInterval> | null = null
let shouldApplyPollingUpdates = true
await withGenerationActive(async () => {
try {
const [preparedImagePath, preparedLastImagePath, preparedAudioPath] = await Promise.all([
prepareGenerationInput(imagePath),
prepareGenerationInput(lastImagePath),
prepareGenerationInput(audioPath),
])
const preparedKeyframes = await Promise.all(
(imageInputs?.keyframes ?? []).map(async keyframe => ({
...keyframe,
path: (await prepareGenerationInput(keyframe.path)) ?? keyframe.path,
})),
)
// Prepare JSON body
const body: Record<string, unknown> = {
prompt,
model: settings.model,
duration: settings.duration,
resolution: settings.videoResolution,
fps: settings.fps,
audio: settings.audio,
cameraMotion: settings.cameraMotion,
negativePrompt: (settings as { negativePrompt?: string }).negativePrompt ?? '',
aspectRatio: settings.aspectRatio || '16:9',
...buildGenerateVideoImageInputs({
mode: imageInputs?.mode ?? 'video',
imagePath: preparedImagePath,
lastImagePath: preparedLastImagePath,
keyframes: preparedKeyframes,
}),
}
if (preparedAudioPath) {
body.audioPath = preparedAudioPath
}
if (settings.loras?.length) {
body.loras = settings.loras.map(l => ({ ref: l.ref, scale: l.scale }))
}
// Poll for real progress from backend with time-based interpolation
let lastPhase = ''
let inferenceStartTime = 0
// Estimated inference time in seconds based on model
const estimatedInferenceTime = settings.model.startsWith('pro') ? 120 : 45
const pollProgress = async () => {
if (!shouldApplyPollingUpdates) return
const result = await ApiClient.getGenerationProgress()
if (!result.ok || !shouldApplyPollingUpdates) return
const data = result.data
let displayProgress = data.progress
let statusMessage = getPhaseMessage(data.phase)
// Time-based interpolation during inference phase
if (data.phase === 'inference') {
if (lastPhase !== 'inference') {
inferenceStartTime = Date.now()
}
const elapsed = (Date.now() - inferenceStartTime) / 1000
// Interpolate from 15% to 95% based on estimated time
const inferenceProgress = Math.min(elapsed / estimatedInferenceTime, 0.95)
displayProgress = 15 + Math.floor(inferenceProgress * 80)
}
// Keep API/local completion as a terminal response state, not polling state.
// Polling complete means backend state is finalized, but request can still be in-flight.
if (data.phase === 'complete' || data.status === 'complete') {
displayProgress = 95
statusMessage = 'Finalizing...'
}
lastPhase = data.phase
setState(prev => {
if (prev.isCancelling) {
return { ...prev, statusMessage: 'Cancelling…' }
}
return {
...prev,
progress: displayProgress,
statusMessage,
}
})
}
progressInterval = setInterval(pollProgress, 500)
// Start generation (HTTP POST - synchronous, returns when done)
// Do not abort this POST: liveness suppression stays up until the
// backend returns {status: "cancelled"} and the GPU job unwinds.
const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest)
shouldApplyPollingUpdates = false
if (!result.ok) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: result,
}))
return
}
const payload = result.data
if (payload.status === 'complete') {
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 100,
statusMessage: 'Complete!',
videoPath: payload.video_path,
imagePath: null,
imagePaths: [],
error: null,
})
} else if (payload.status === 'cancelled') {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
statusMessage: 'Cancelled',
}))
} else {
throw new Error('Unexpected response from /api/generate')
}
} catch (error) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
}))
} finally {
shouldApplyPollingUpdates = false
if (progressInterval) {
clearInterval(progressInterval)
}
}
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const cancel = useCallback(() => {
let claimedCancelling = false
setState(prev => {
if (!prev.isGenerating || prev.isCancelling) return prev
claimedCancelling = true
return {
...prev,
isCancelling: true,
statusMessage: 'Cancelling…',
}
})
// Always POST — retake/extend/IC-LoRA Stop reuse this while this hook is idle.
void (async () => {
const result = await ApiClient.cancelGeneration()
const accepted = result.ok && result.data.status === 'cancelling'
if (accepted || !claimedCancelling) return
setState(prev => {
if (!prev.isCancelling) return prev
return { ...prev, isCancelling: false }
})
})()
}, [])
const generateImage = useCallback(async (
prompt: string,
settings: GenerationSettings,
editSource?: string | null,
) => {
const isEditing = !!editSource
const openFalConnectDialog = () => {
window.dispatchEvent(new CustomEvent('open-api-gateway', {
detail: {
requiredKeys: ['fal'],
title: 'Connect FAL AI',
description: `FAL AI is required for ${isEditing ? 'editing' : 'generating'} images with Z Image Turbo when API generations are enabled.`,
blocking: false,
},
}))
}
if (shouldImageGenerateWithFalApi) {
const settingsResult = await ApiClient.getSettings()
const hasFalApiKey = settingsResult.ok ? settingsResult.data.hasFalApiKey : appSettings.hasFalApiKey
if (!hasFalApiKey) {
if (settingsResult.ok) void refreshSettings()
openFalConnectDialog()
return
}
}
const numImages = settings.variations || 1
setState({
isGenerating: true,
isCancelling: false,
canCancel: canCancelLocalJob('image', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
progress: 0,
statusMessage: isEditing
? 'Editing image...'
: numImages > 1 ? `Generating ${numImages} images...` : 'Generating image...',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
await withGenerationActive(async () => {
let progressInterval: ReturnType<typeof setInterval> | null = null
try {
const preparedEditSource = await prepareGenerationInput(editSource)
// Skip prompt enhancement for T2I - use original prompt directly
const finalPrompt = prompt
// Edit runs at the source image's resolution; width/height are ignored server-side.
const dims = isEditing ? { width: 1024, height: 1024 } : getImageDimensions(settings)
const numSteps = settings.imageSteps || (isEditing ? 8 : 4)
// Poll for progress
const pollProgress = async () => {
const result = await ApiClient.getGenerationProgress()
if (!result.ok) return
const data = result.data
const currentImage = data.currentStep || 0
const totalImages = data.totalSteps || numImages
setState(prev => {
if (prev.isCancelling) {
return { ...prev, statusMessage: 'Cancelling…' }
}
return {
...prev,
progress: data.progress,
statusMessage: data.phase === 'loading_model'
? 'Loading Z-Image Turbo model...'
: data.phase === 'inference'
? isEditing
? 'Editing image...'
: numImages > 1
? `Generating image ${currentImage + 1}/${totalImages}...`
: 'Generating image...'
: data.phase === 'complete'
? 'Complete!'
: 'Generating...',
}
})
}
progressInterval = setInterval(pollProgress, 500)
const imageRequest: GenerateImageRequest = {
prompt: finalPrompt,
width: dims.width,
height: dims.height,
numSteps,
numImages,
// strength is ignored server-side unless imagePath is set, but the request type
// requires it — send the default rather than the edit-only setting when not editing.
strength: isEditing ? (settings.imageEditStrength ?? 0.6) : 0.6,
...(isEditing && preparedEditSource ? { imagePath: preparedEditSource } : {}),
}
const result = await ApiClient.generateImage(imageRequest)
if (!result.ok) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: result,
}))
return
}
const payload = result.data
if (payload.status === 'complete') {
const rawPaths = payload.image_paths
if (rawPaths.length === 0) {
throw new Error('Image generation completed without output images')
}
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 100,
statusMessage: 'Complete!',
videoPath: null,
imagePath: rawPaths[0],
imagePaths: rawPaths,
error: null,
})
} else if (payload.status === 'cancelled') {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
statusMessage: 'Cancelled',
}))
} else {
throw new Error('Unexpected response from /api/generate-image')
}
} catch (error) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
}))
} finally {
if (progressInterval) {
clearInterval(progressInterval)
}
}
})
}, [appSettings.hasFalApiKey, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings])
const reset = useCallback(() => {
clearRecoveryPolling()
localStorage.removeItem(GENERATION_RECOVERY_KEY)
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 0,
statusMessage: '',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
}, [])
return {
...state,
generate,
generateImage,
cancel,
reset,
resumeIfRunning,
}
}
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
import { GENERATION_RECOVERY_KEY, readRecoveryMarkerCanCancel } from './use-generation'
export interface GlobalGenerationLock {
// Fail-closed Generate disable: a recovery marker, a running poll, or an unconfirmed
// poll error all mean the single global slot must be treated as busy.
isRunning: boolean
// Same poll as isRunning, gated on the backend's frozen local-GPU vs API slot.
// Never derived from live Settings — switching to local mid-API-job must not reveal Stop.
canCancel: boolean
// Slot still busy after Stop (`status=running`, `phase=cancelled`) until generate() unwinds.
isCancelling: boolean
}
// Only one generation can run at a time across the whole app (single global backend slot), but
// each project's GenSpace only tracks its OWN local isGenerating-style state — it has no idea a
// different project (or the same one, reconnected via a stale click) is already occupying that
// slot. Without this, Generate stays clickable in project B while project A is mid-generation;
// the request 409s, but only after writeRecoveryContext already overwrote A's in-flight recovery
// marker with B's (now-failed) one. Polling here lets Generate disable proactively instead.
// Stop uses the same sources: the recovery marker (immediate, frozen at job start) and
// GET /generation/progress.cancellable (authoritative once a poll lands). Hook-local canCancel
// dies on UI refresh; these do not. Live Settings must not flip Stop mid-job.
// No marker anywhere means nothing CAN be running (see subscribeWhileGenerationMayBeActive), so
// idle starts unlocked and costs no network call; once a marker exists and we're actually
// polling, an unconfirmed/failed poll is treated as locked rather than silently trusting "not
// running" — that unconfirmed-failure gap is exactly what previously let Generate stay clickable
// during another project's generation. The initial state has to check the marker too, not just
// hardcode false: a page refresh resets this hook's React state from scratch while another
// project's marker (and its still-running backend generation) survives in localStorage, and the
// first poll takes a network round trip to resolve — that gap is otherwise the same unconfirmed
// window all over again, just re-opened on every reload instead of only at first app launch.
export function useGlobalGenerationLock(): GlobalGenerationLock {
const [lock, setLock] = useState<GlobalGenerationLock>(() => ({
isRunning: localStorage.getItem(GENERATION_RECOVERY_KEY) != null,
canCancel: readRecoveryMarkerCanCancel(),
isCancelling: false,
}))
useEffect(() => subscribeWhileGenerationMayBeActive(result => {
if (!result.ok) {
// Same fail-closed Generate lock as before. Stop keeps the frozen marker bit rather
// than guessing — a starved poll during local GPU work must not hide Stop, and a
// failed poll during an API job must not reveal it.
setLock({
isRunning: true,
canCancel: readRecoveryMarkerCanCancel(),
isCancelling: false,
})
return
}
const isRunning = result.data.status === 'running'
setLock({
isRunning,
canCancel: isRunning && result.data.cancellable,
isCancelling: isRunning && result.data.phase === 'cancelled',
})
}), [])
return lock
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
interface UseHfAuthResult {
hfAuthStatus: HfAuthStatus
hfAuthPolling: boolean
startHuggingFaceLogin: () => Promise<void>
handleHuggingFaceLogout: () => Promise<void>
}
const NOOP = async () => {}
export function useHfAuth(enabled: boolean): UseHfAuthResult {
// Used for gated downloads (LTX 2.5 base models, catalog LoRAs / IC-LoRAs).
const [hfAuthStatus, setHfAuthStatus] = useState<HfAuthStatus>('not_authenticated')
const [hfAuthPolling, setHfAuthPolling] = useState(false)
// One-time check when enabled becomes true
useEffect(() => {
if (!enabled) return
const checkAuth = async () => {
const result = await ApiClient.getHuggingFaceAuthStatus()
if (!result.ok) {
logger.error(`HF auth status check failed: ${result.error.message}`)
return
}
setHfAuthStatus(result.data.status)
}
void checkAuth()
}, [enabled])
// Poll while waiting for user to complete auth in browser
useEffect(() => {
if (!hfAuthPolling) return
const interval = setInterval(async () => {
const result = await ApiClient.getHuggingFaceAuthStatus()
if (!result.ok) {
logger.error(`HF auth status check failed: ${result.error.message}`)
return
}
const { status } = result.data
setHfAuthStatus(status)
if (status === 'authenticated') setHfAuthPolling(false)
}, 2000)
return () => clearInterval(interval)
}, [hfAuthPolling])
const startHuggingFaceLogin = useCallback(async () => {
const result = await ApiClient.startHuggingFaceLogin()
if (!result.ok) {
logger.error(`HF login failed: ${result.error.message}`)
return
}
const params = result.data
setHfAuthPolling(true)
await window.electronAPI.openHuggingFaceAuth({
clientId: params.client_id,
redirectUri: params.redirect_uri,
scope: params.scope,
state: params.state,
codeChallenge: params.code_challenge,
codeChallengeMethod: params.code_challenge_method,
})
}, [])
const handleHuggingFaceLogout = useCallback(async () => {
const result = await ApiClient.huggingFaceLogout()
if (!result.ok) {
logger.error(`HF logout failed: ${result.error.message}`)
return
}
setHfAuthStatus('not_authenticated')
}, [])
if (!enabled) {
return {
hfAuthStatus: 'authenticated',
hfAuthPolling: false,
startHuggingFaceLogin: NOOP,
handleHuggingFaceLogout: NOOP,
}
}
return {
hfAuthStatus,
hfAuthPolling,
startHuggingFaceLogin,
handleHuggingFaceLogout,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
type CheckModelAccessBody = NonNullable<ApiRequestBodyOf<'checkModelAccess'>>
type ModelCheckpointID = NonNullable<CheckModelAccessBody['cp_ids']>[number]
interface UseHfModelAccessResult {
accessMap: ModelAccessMap
allAuthorized: boolean
checking: boolean
/** Set when the access check itself failed — distinct from "not authorized". */
checkError: string | null
recheckAccess: () => void
}
export function useHfModelAccess(modelTypes: readonly ModelCheckpointID[], hfAuthStatus: HfAuthStatus): UseHfModelAccessResult {
const [accessMap, setAccessMap] = useState<ModelAccessMap>({})
const [checking, setChecking] = useState(false)
const [polling, setPolling] = useState(false)
const [checkError, setCheckError] = useState<string | null>(null)
// Nothing to authorize, or every returned repo is authorized. Empty map with pending
// checkpoints is NOT authorized — that covers both "still checking" and a failed check
// (which leaves the map empty and sets checkError for the gate to show).
const allAuthorized = modelTypes.length === 0
|| (Object.keys(accessMap).length > 0
&& Object.values(accessMap).every((status) => status === 'authorized'))
const doCheck = useCallback(async () => {
if (modelTypes.length === 0) return
setChecking(true)
const result = await ApiClient.checkModelAccess({ cp_ids: [...modelTypes] })
if (!result.ok) {
logger.error(`Model access check failed: ${result.error.message}`)
setAccessMap({})
setCheckError(result.error.message)
setChecking(false)
return
}
const { access } = result.data
setAccessMap(access)
setCheckError(null)
const allOk = Object.values(access).every((s) => s === 'authorized')
if (allOk) setPolling(false)
setChecking(false)
}, [modelTypes])
// Signed out still needs a check: gated repos (LTX 2.5) can't be downloaded without a token.
useEffect(() => {
if (modelTypes.length === 0) {
setAccessMap((current) => (Object.keys(current).length === 0 ? current : {}))
setCheckError(null)
setPolling(false)
return
}
void doCheck()
setPolling(true)
}, [hfAuthStatus, modelTypes.length, doCheck])
// Poll while any model is not_authorized
useEffect(() => {
if (!polling || hfAuthStatus !== 'authenticated') return
const interval = setInterval(() => { void doCheck() }, 5000)
return () => clearInterval(interval)
}, [polling, hfAuthStatus, doCheck])
const recheckAccess = useCallback(() => {
void doCheck()
}, [doCheck])
return { accessMap, allAuthorized, checking, checkError, recheckAccess }
}
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiSuccessOf } from '../lib/api-client'
import { useIcLoras, type IcLoraListItem } from './use-catalog'
import { catalogItemToEntry, preferredVariantId, resolveDownloadedVariantIds } from '../lib/lora-library'
// Owns the IC-LoRA library wiring: the catalog hook (fetch + download), the modal open state,
// the selected recipe id (+ optional variant), and the catalog→entry mapping for the modal.
// Selection *side effects* (seeding the generation controls/settings from the picked recipe)
// stay with the caller via onSelect — they touch GenSpace gen state.
export function useIcLoraLibrary(
enabled: boolean,
onSelect: (item: IcLoraListItem | null) => void,
installed: ApiSuccessOf<'listModels'>['models'] = [],
refreshInstalled: () => void = () => {},
) {
const { icLoras, downloadIcLora, downloadingKey, progress, downloadError } = useIcLoras(enabled)
const [selectedIcLoraId, setSelectedIcLoraId] = useState<string | null>(null)
const [selectedIcLoraVariantId, setSelectedIcLoraVariantId] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const installedPaths = useMemo(() => installed.map(m => m.path), [installed])
const items = useMemo(
() => icLoras.map(r => {
const base = catalogItemToEntry(r.ic_lora)
const downloadedVariantIds = resolveDownloadedVariantIds(
base.variants,
r.downloaded_variant_ids,
installedPaths,
)
return {
...base,
downloaded: r.downloaded || downloadedVariantIds.length > 0,
downloadedVariantIds,
}
}),
[icLoras, installedPaths],
)
// Re-list on-disk IC-LoRAs once a download finishes so per-variant ✓ / Use stay accurate
// even if the catalog list payload lags or omits downloaded_variant_ids.
const prevDownloadingKey = useRef<string | null>(null)
useEffect(() => {
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
prevDownloadingKey.current = downloadingKey
}, [downloadingKey, refreshInstalled])
const selectIcLora = useCallback(
(item: IcLoraListItem | null, variantId?: string | null) => {
setSelectedIcLoraId(item?.ic_lora.id ?? null)
if (!item) {
setSelectedIcLoraVariantId(null)
} else {
const entry = items.find(e => e.id === item.ic_lora.id)
const preferred = preferredVariantId(
entry?.variants,
entry?.defaultVariantId,
entry?.downloadedVariantIds,
)
// Explicit variant wins; otherwise only an *installed* preferred id (never undownloaded default).
setSelectedIcLoraVariantId(variantId ?? preferred ?? null)
}
onSelect(item)
},
[onSelect, items],
)
return {
icLoras,
items,
downloadIcLora,
downloadingKey,
progress,
downloadError,
modalOpen,
setModalOpen,
selectedIcLoraId,
selectedIcLoraVariantId,
selectIcLora,
}
}
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useState } from 'react'
import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client'
import { withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
export type IcLoraConditioningType = 'canny' | 'depth' | 'custom'
export type IcLoraAudioMode = 'source' | 'generated' | 'off'
export interface IcLoraSubmitParams {
videoPath: string
conditioningType: IcLoraConditioningType
conditioningStrength: number
prompt: string
// "custom": the user's own IC-LoRA weights + a pre-rendered control video.
customLoraRef?: string
controlVideoPath?: string
// Skip Stage 2 refine — transformation IC-LoRAs need this; default off.
skipStage2?: boolean
// Keep the IC-LoRA active during Stage 2 refine (only when Stage 2 runs). Default off.
// On: the transformation survives refinement and the output lands at the chosen resolution.
useLoraInStage2?: boolean
// Target output resolution for the useLoraInStage2 path (undefined = source). Backend
// snaps it down to a valid size and never upscales above source.
resolution?: { width: number; height: number }
// Stage-1-only canvas multiplier: 2.0 = native, 1.0 = half. Only used when skipStage2.
resolutionFactor?: number
// Soundtrack: source (input clip) / generated (prompt) / off.
audioMode?: IcLoraAudioMode
// LoRA adapter merge weight (load-time). Default 1.0.
loraStrength?: number
// Override control-video fps (decimate to fewer frames). undefined = source fps.
fpsOverride?: number
// IC-LoRA mode: catalog IC-LoRA id + the user's input media path + output duration.
// When icLoraId is set, the backend resolves catalog weights and builds the
// control video via the IC-LoRA's preprocessing pipeline.
icLoraId?: string
// Catalog download.variants[].id when the entry has multiple checkpoints.
variantId?: string
inputPath?: string
// Values for the IC-LoRA's declared controls, keyed by control id (e.g. duration).
// Sent verbatim; the backend validates + maps them to behaviour.
controlValues?: Record<string, number | string>
// Outpainting (position_canvas control): per-edge pixels to add around the source.
outpaintPads?: { left: number; right: number; top: number; bottom: number }
// When true, an empty prompt is allowed (set from the IC-LoRA's allows_empty_prompt).
allowEmptyPrompt?: boolean
// Optional reference image for catalog IC-LoRAs that opt in (allows_reference_image).
// Sent as image conditioning at frame 0, strength 1.0.
referenceImagePath?: string
}
export interface IcLoraResult {
videoPath: string
}
interface UseIcLoraState {
isGenerating: boolean
canCancel: boolean
status: string
error: string | null
result: IcLoraResult | null
}
type GenerateIcLoraBody = ApiRequestBodyOf<'generateIcLora'>
export function useIcLora() {
const [state, setState] = useState<UseIcLoraState>({
isGenerating: false,
canCancel: false,
status: '',
error: null,
result: null,
})
const submitIcLora = useCallback(async (params: IcLoraSubmitParams) => {
if (!params.prompt.trim() && !params.allowEmptyPrompt) return
// IC-LoRA mode needs inputPath; canny/depth/custom need videoPath.
if (!params.icLoraId && !params.videoPath) return
if (params.icLoraId && !params.inputPath) return
setState({
isGenerating: true,
// IC-LoRA is always local GPU (the tab is hidden when forceApiGenerations).
// Do not gate on shouldVideoGenerateWithLtxApi — that flag is for t2v/i2v.
canCancel: true,
status: 'Generating',
error: null,
result: null,
})
await withGenerationActive(async () => {
const result = await ApiClient.generateIcLora({
video_path: params.videoPath,
conditioning_type: params.conditioningType,
conditioning_strength: params.conditioningStrength,
prompt: params.prompt,
custom_lora_ref: params.customLoraRef,
control_video_path: params.controlVideoPath,
skip_stage_2: params.skipStage2,
use_lora_in_stage_2: params.useLoraInStage2,
resolution: params.resolution,
resolution_factor: params.resolutionFactor,
audio_mode: params.audioMode,
lora_strength: params.loraStrength,
fps_override: params.fpsOverride,
ic_lora_id: params.icLoraId,
variant_id: params.variantId,
input_path: params.inputPath,
control_values: params.controlValues,
outpaint_pads: params.outpaintPads,
images: params.referenceImagePath
? [{ path: params.referenceImagePath, frame: 0, strength: 1.0 }]
: [],
} as GenerateIcLoraBody)
if (!result.ok) {
logger.error(`IC-LoRA error: ${result.error.message}`)
setState({
isGenerating: false,
canCancel: false,
status: '',
error: result.error.message,
result: null,
})
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({
isGenerating: false,
canCancel: false,
status: 'Cancelled',
error: null,
result: null,
})
return
}
if (payload.status === 'complete') {
setState({
isGenerating: false,
canCancel: false,
status: 'Generation complete!',
error: null,
result: {
videoPath: payload.video_path,
},
})
return
}
})
}, [])
const reset = useCallback(() => {
setState({
isGenerating: false,
canCancel: false,
status: '',
error: null,
result: null,
})
}, [])
return {
submitIcLora,
resetIcLora: reset,
isIcLoraGenerating: state.isGenerating,
canCancel: state.canCancel,
icLoraStatus: state.status,
icLoraError: state.error,
icLoraResult: state.result,
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiSuccessOf } from '../lib/api-client'
import type { LoraSelection } from '../components/SettingsPanel'
import { useLoraCatalog } from './use-catalog'
import {
mergeLoraLibrary,
resolveInstalledPath,
variantDisplayName,
type LibraryEntry,
} from '../lib/lora-library'
// Owns the plain-LoRA library wiring: the catalog hook, the catalog ∪ on-disk merge, the
// modal open state, "use this LoRA" → selectedLoras, and re-listing installed files when a
// download finishes. Kept out of GenSpace so the view just consumes the result.
export function useLoraLibrary(
enabled: boolean,
installed: ApiSuccessOf<'listModels'>['models'],
selected: LoraSelection[],
onSelectedChange: (loras: LoraSelection[]) => void,
refreshInstalled: () => void,
) {
const { loras, downloadLora, downloadingKey, progress, downloadError } = useLoraCatalog(enabled)
const [modalOpen, setModalOpen] = useState(false)
const [useError, setUseError] = useState<string | null>(null)
const items = useMemo(() => mergeLoraLibrary(loras, installed), [loras, installed])
// Re-list on-disk files once a download finishes so the new file appears + is usable.
const prevDownloadingKey = useRef<string | null>(null)
useEffect(() => {
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
prevDownloadingKey.current = downloadingKey
}, [downloadingKey, refreshInstalled])
// Returns whether the LoRA was actually added, so the modal knows whether it's safe to
// close. resolveInstalledPath never substitutes a different variant's path — if the catalog
// says this variant is downloaded but its path hasn't landed yet (listModels refresh race),
// re-trigger the refresh and surface a retry message instead of silently using the wrong file.
const useEntry = useCallback((e: LibraryEntry, variantId?: string) => {
const path = resolveInstalledPath(e, variantId)
if (!path) {
if (variantId && e.downloadedVariantIds?.includes(variantId)) {
refreshInstalled()
setUseError('Still syncing installed files — try again in a moment.')
}
return false
}
setUseError(null)
if (selected.some(s => s.ref === path)) return true
const variantLabel = variantId ? e.variants?.find(v => v.id === variantId)?.label : undefined
onSelectedChange([
...selected,
{
ref: path,
name: variantDisplayName(e.name, variantLabel, e.variants?.length),
scale: e.recommendedStrength ?? 1.0,
catalogId: e.id,
},
])
return true
}, [selected, onSelectedChange, refreshInstalled])
return {
items, downloadLora, downloadingKey, progress, downloadError,
modalOpen, setModalOpen, useEntry, useError,
}
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import { useAppSettings } from '../contexts/AppSettingsContext'
export type EnhanceProvider = 'local' | 'api'
interface UsePromptEnhancerProviderResult {
// Local requires the Gemma text-encoder checkpoint to be downloaded AND local generation to
// actually be usable this run (e.g. not memory-constrained into API-only mode); API requires a
// stored Gemini key to actually run, but the option stays selectable without one so Enhance
// can send the user to Settings instead of hiding the choice.
hasLocalTextEncoder: boolean
hasGeminiApiKey: boolean
// The provider Enhance will actually use: the persisted preference when it's currently
// choosable. API remains choosable without a key (clicking Enhance then opens Settings).
// Local that's temporarily unavailable (e.g. memory-constrained run) falls back silently —
// it does NOT overwrite the persisted preference, which only an explicit setProviderPreference
// call changes.
provider: EnhanceProvider
// Shown when local Enhance is available, so the user can still pick API (Gemini) before
// they've added a key. Hidden when local isn't an option — the button is already API-only.
canToggleProvider: boolean
setProviderPreference: (provider: EnhanceProvider) => void
}
// Single source of truth for which prompt-enhancer provider (local Gemma text encoder vs.
// Gemini's hosted API) is available and which one Enhance should use. `enabled` gates the local
// checkpoint lookup so it only fires once the enhancer could plausibly be shown for the current
// mode.
export function usePromptEnhancerProvider(enabled: boolean): UsePromptEnhancerProviderResult {
const {
settings: { hasGeminiApiKey, promptEnhancerProviderPreference },
updateSettings,
forceApiGenerations,
modelsVersion,
} = useAppSettings()
const [isLocalEncoderUsable, setIsLocalEncoderUsable] = useState(false)
useEffect(() => {
if (!enabled) return
let cancelled = false
void ApiClient.getTextEncoderRecommendation().then((result) => {
// Deliberately not cp_to_download: the encoder that runs generations isn't always the one
// that can enhance (LTX 2.5's encodes only, and enhances from a separate checkpoint), so
// the backend reports enhancer availability on its own.
if (!cancelled) {
setIsLocalEncoderUsable(result.ok && result.data.local_enhancement_supported)
}
})
return () => { cancelled = true }
// modelsVersion: the enhancer is a download the user can make mid-session, and Enhance should
// become available without a restart.
}, [enabled, modelsVersion])
// Downloaded isn't enough on its own — forceApiGenerations is the pure "insufficient memory
// for local models this run" signal (deliberately NOT shouldVideoGenerateWithLtxApi, which
// also folds in the user's own preference to use the LTX API for VIDEO specifically — that's
// unrelated to whether the much smaller Gemma text encoder can run locally right now).
const hasLocalTextEncoder = isLocalEncoderUsable && !forceApiGenerations
const canToggleProvider = hasLocalTextEncoder
// Default to local when the user hasn't made an explicit choice, or when they asked for
// local and it's currently usable. API preference is honored even without a Gemini key so
// the Enhance (API) option isn't silently replaced by local.
const provider: EnhanceProvider =
promptEnhancerProviderPreference === 'api' ? 'api'
: promptEnhancerProviderPreference === 'local' && hasLocalTextEncoder ? 'local'
: hasLocalTextEncoder ? 'local'
: 'api'
const setProviderPreference = (next: EnhanceProvider) => {
updateSettings({ promptEnhancerProviderPreference: next })
}
return {
hasLocalTextEncoder,
hasGeminiApiKey,
provider,
canToggleProvider,
setProviderPreference,
}
}
+165
View File
@@ -0,0 +1,165 @@
import { useCallback, useState } from 'react'
import type { components } from '../generated/backend-openapi'
import { ApiClient } from '../lib/api-client'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
import { prepareGenerationInput } from '../lib/generation-input'
import { useAppSettings } from '../contexts/AppSettingsContext'
export type RetakeMode = 'replace_audio_and_video' | 'replace_video' | 'replace_audio'
// ltxv-api /v1/retake and /v2/extend accept ltx-2-pro / ltx-2-3-pro.
// Desktop maps those to pipeline "pro".
export type RetakeExtendModel = components['schemas']['RetakeRequest']['model']
// Runtime options for the retake/extend MODEL dropdown. Checked against the OpenAPI union
// so a schema change that adds/removes a value fails typecheck until this list is updated.
export const RETAKE_EXTEND_MODELS = ['pro'] as const satisfies ReadonlyArray<RetakeExtendModel>
/** Map a persisted video pipeline id onto the nearest retake/extend model. */
export function retakeExtendModelFromPipeline(
_model: string | undefined | null,
): RetakeExtendModel {
return 'pro'
}
export interface RetakeSubmitParams {
videoPath: string
startTime: number
duration: number
prompt: string
mode: RetakeMode
resolution?: { width: number; height: number }
model: RetakeExtendModel
}
export interface RetakeResult {
videoPath: string
}
interface UseRetakeState {
isRetaking: boolean
canCancel: boolean
retakeStatus: string
retakeError: string | null
result: RetakeResult | null
}
export function useRetake() {
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
const [state, setState] = useState<UseRetakeState>({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: null,
result: null,
})
const submitRetake = useCallback(async (params: RetakeSubmitParams) => {
if (!params.videoPath) return
setState({
isRetaking: true,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
retakeStatus: 'Generating',
retakeError: null,
result: null,
})
await withGenerationActive(async () => {
let preparedVideoPath: string
try {
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
logger.error(`Retake input error: ${message}`)
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: message,
result: null,
})
return
}
const result = await ApiClient.retake({
video_path: preparedVideoPath,
start_time: params.startTime,
duration: params.duration,
prompt: params.prompt,
mode: params.mode,
resolution: params.resolution,
model: params.model,
})
if (!result.ok) {
logger.error(`Retake error: ${result.error.message}`)
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: result.error.message,
result: null,
})
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: 'Cancelled',
retakeError: null,
result: null,
})
return
}
if ('video_path' in payload) {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: 'Retake complete!',
retakeError: null,
result: {
videoPath: payload.video_path,
},
})
return
}
logger.error(`Retake completed without local video payload: ${JSON.stringify(payload.result)}`)
const errorMsg = 'Retake completed but no local video file was returned'
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: errorMsg,
result: null,
})
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const resetRetake = useCallback(() => {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: null,
result: null,
})
}, [])
return {
submitRetake,
resetRetake,
isRetaking: state.isRetaking,
canCancel: state.canCancel,
retakeStatus: state.retakeStatus,
retakeError: state.retakeError,
retakeResult: state.result,
}
}
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { ApiClient } from '../lib/api-client'
import type { VideoGenerationModelSpecsResponse } from '../lib/video-generation-model-specs'
interface VideoGenerationModelSpecsState {
modelSpecs: VideoGenerationModelSpecsResponse | null
isLoading: boolean
errorMessage: string | null
}
export function useVideoGenerationModelSpecs(): VideoGenerationModelSpecsState {
// Local specs describe the *active* LTX version, so they go stale as soon as the user
// downloads, deletes, or activates a version — refetch on the models-changed signal.
const { modelsVersion } = useAppSettings()
const [state, setState] = useState<VideoGenerationModelSpecsState>({
modelSpecs: null,
isLoading: true,
errorMessage: null,
})
useEffect(() => {
const abortController = new AbortController()
let isActive = true
void (async () => {
const result = await ApiClient.getGenerateVideoModelSpecs(undefined, {
signal: abortController.signal,
})
if (!isActive) return
if (result.ok) {
setState({
modelSpecs: result.data,
isLoading: false,
errorMessage: null,
})
return
}
setState({
modelSpecs: null,
isLoading: false,
errorMessage: result.error.message,
})
})()
return () => {
isActive = false
abortController.abort()
}
}, [modelsVersion])
return state
}
@@ -0,0 +1,150 @@
import { useCallback, useRef, useState } from 'react'
import { useProjects } from '../contexts/ProjectContext'
import { projectReferenceSchema, projectSchema, type Project } from '../types/project-model'
import {
PROJECT_IDS_STORAGE_KEY,
PROJECT_STORAGE_KEY_PREFIX,
getProjectStorageKey,
readProject,
readProjectIds,
writeProjectIds,
} from '../lib/project-storage'
import { logger } from '../lib/logger'
export type ProjectReferencesMigrationStatus =
| { status: 'needed' }
| { status: 'inProgress'; ratio: number }
| { status: 'completed' }
const LEGACY_PROJECTS_STORAGE_KEY = 'ltx-projects'
interface LegacyProjectRecord {
projectData: unknown
projectId: string
}
function yieldToUi(): Promise<void> {
return new Promise(resolve => {
window.setTimeout(resolve, 0)
})
}
export function hasLegacyProjectsEntry(): boolean {
return localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY) !== null
}
export function deleteLegacyProjectsEntry(): void {
localStorage.removeItem(LEGACY_PROJECTS_STORAGE_KEY)
}
export function writeLegacyProjects(projects: readonly Project[]): Project[] {
const normalizedProjects = projects.map(project => projectSchema.parse(project))
localStorage.setItem(
LEGACY_PROJECTS_STORAGE_KEY,
JSON.stringify(normalizedProjects),
)
return normalizedProjects
}
export function readLegacyProjects(): LegacyProjectRecord[] {
const stored = localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY)
if (!stored) return []
const parsed = JSON.parse(stored)
if (!Array.isArray(parsed)) {
throw new Error('Legacy projects payload is not an array')
}
return parsed.map(projectData => ({
projectData,
projectId: projectReferenceSchema.parse(projectData).id,
}))
}
export function writeRawProject(projectId: string, projectData: unknown): void {
localStorage.setItem(
getProjectStorageKey(projectId),
JSON.stringify(projectData),
)
}
export function deleteProjectIdsEntry(): void {
localStorage.removeItem(PROJECT_IDS_STORAGE_KEY)
}
export function readProjectsFromReferences(): Project[] {
return readProjectIds().map(projectId => {
const project = readProject(projectId)
if (!project) {
throw new Error(`Missing project entry for id ${projectId}`)
}
return project
})
}
export function deleteAllProjectEntries(): void {
const keysToDelete: string[] = []
for (let i = 0; i < localStorage.length; i += 1) {
const storageKey = localStorage.key(i)
if (storageKey?.startsWith(PROJECT_STORAGE_KEY_PREFIX)) {
keysToDelete.push(storageKey)
}
}
for (const storageKey of keysToDelete) {
localStorage.removeItem(storageKey)
}
}
export function useProjectReferencesMigration() {
const { reloadProjectIds } = useProjects()
const [migrationStatus, setMigrationStatus] = useState<ProjectReferencesMigrationStatus>(() => (
hasLegacyProjectsEntry() ? { status: 'needed' } : { status: 'completed' }
))
const inFlightRef = useRef<Promise<void> | null>(null)
const migrateProjects = useCallback(async () => {
if (inFlightRef.current) return inFlightRef.current
const runMigration = async () => {
if (!hasLegacyProjectsEntry()) {
reloadProjectIds()
setMigrationStatus({ status: 'completed' })
return
}
setMigrationStatus({ status: 'inProgress', ratio: 0 })
await yieldToUi()
try {
const legacyProjects = readLegacyProjects()
const total = legacyProjects.length
for (const [index, project] of legacyProjects.entries()) {
writeRawProject(project.projectId, project.projectData)
setMigrationStatus({
status: 'inProgress',
ratio: total === 0 ? 1 : (index + 1) / total,
})
await yieldToUi()
}
writeProjectIds(legacyProjects.map(project => project.projectId))
deleteLegacyProjectsEntry()
reloadProjectIds()
setMigrationStatus({ status: 'completed' })
} catch (error) {
logger.error(`Failed to migrate project references: ${error}`)
setMigrationStatus({ status: 'needed' })
}
}
inFlightRef.current = runMigration().finally(() => {
inFlightRef.current = null
})
return inFlightRef.current
}, [reloadProjectIds])
return { migrationStatus, migrateProjects }
}