Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user