Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { resetBackendCredentials } from '../lib/backend'
|
||||
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
|
||||
|
||||
export interface AppSettings {
|
||||
useTorchCompile: boolean
|
||||
diffusionStageCacheEnabled: boolean
|
||||
hasLtxApiKey: boolean
|
||||
userPrefersLtxApiVideoGenerations: boolean
|
||||
hasFalApiKey: boolean
|
||||
userPrefersFalApiImageGenerations: boolean
|
||||
hasGeminiApiKey: boolean
|
||||
geminiModel: string
|
||||
useLocalTextEncoder: boolean
|
||||
promptCacheSize: number
|
||||
promptEnhancerEnabledT2V: boolean
|
||||
promptEnhancerEnabledI2V: boolean
|
||||
// The user's explicit prompt-enhancer provider choice, persisted so it survives restarts.
|
||||
// null means no active choice yet — the enhancer defaults to whichever provider is available
|
||||
// without writing that default back here; only an explicit pick (never an automatic fallback
|
||||
// when the preferred provider is temporarily unavailable) sets this.
|
||||
promptEnhancerProviderPreference: 'local' | 'api' | null
|
||||
seedLocked: boolean
|
||||
lockedSeed: number
|
||||
modelsDir: string
|
||||
useConvVae: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
useTorchCompile: false,
|
||||
diffusionStageCacheEnabled: false,
|
||||
hasLtxApiKey: false,
|
||||
userPrefersLtxApiVideoGenerations: false,
|
||||
hasFalApiKey: false,
|
||||
userPrefersFalApiImageGenerations: false,
|
||||
hasGeminiApiKey: false,
|
||||
geminiModel: '',
|
||||
useLocalTextEncoder: false,
|
||||
promptCacheSize: 1,
|
||||
promptEnhancerEnabledT2V: false,
|
||||
promptEnhancerEnabledI2V: false,
|
||||
promptEnhancerProviderPreference: null,
|
||||
seedLocked: false,
|
||||
lockedSeed: 42,
|
||||
modelsDir: '',
|
||||
useConvVae: false,
|
||||
}
|
||||
|
||||
type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
|
||||
|
||||
interface AppSettingsContextValue {
|
||||
settings: AppSettings
|
||||
isLoaded: boolean
|
||||
runtimePolicyLoaded: boolean
|
||||
updateSettings: (patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => void
|
||||
refreshSettings: () => Promise<void>
|
||||
saveLtxApiKey: (value: string) => Promise<void>
|
||||
saveFalApiKey: (value: string) => Promise<void>
|
||||
saveGeminiApiKey: (value: string) => Promise<void>
|
||||
forceApiGenerations: boolean
|
||||
shouldVideoGenerateWithLtxApi: boolean
|
||||
shouldImageGenerateWithFalApi: boolean
|
||||
cudaAvailable: boolean
|
||||
// Bumped whenever installed models change (download / delete / activate a version). Generation
|
||||
// model specs are derived from the *active* local model, so anything reading them must refetch;
|
||||
// without this they stay pinned to whatever was installed at app start.
|
||||
modelsVersion: number
|
||||
notifyModelsChanged: () => void
|
||||
}
|
||||
|
||||
const AppSettingsContext = createContext<AppSettingsContextValue | null>(null)
|
||||
|
||||
function toBackendProcessStatus(value: unknown): BackendProcessStatus | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as { status?: unknown }
|
||||
if (record.status === 'alive' || record.status === 'restarting' || record.status === 'dead') {
|
||||
return record.status
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeAppSettings(data: Partial<AppSettings>): AppSettings {
|
||||
return {
|
||||
useTorchCompile: data.useTorchCompile ?? DEFAULT_APP_SETTINGS.useTorchCompile,
|
||||
diffusionStageCacheEnabled: data.diffusionStageCacheEnabled ?? DEFAULT_APP_SETTINGS.diffusionStageCacheEnabled,
|
||||
hasLtxApiKey: data.hasLtxApiKey ?? DEFAULT_APP_SETTINGS.hasLtxApiKey,
|
||||
userPrefersLtxApiVideoGenerations: data.userPrefersLtxApiVideoGenerations ?? DEFAULT_APP_SETTINGS.userPrefersLtxApiVideoGenerations,
|
||||
hasFalApiKey: data.hasFalApiKey ?? DEFAULT_APP_SETTINGS.hasFalApiKey,
|
||||
userPrefersFalApiImageGenerations: data.userPrefersFalApiImageGenerations ?? DEFAULT_APP_SETTINGS.userPrefersFalApiImageGenerations,
|
||||
hasGeminiApiKey: data.hasGeminiApiKey ?? DEFAULT_APP_SETTINGS.hasGeminiApiKey,
|
||||
geminiModel: data.geminiModel ?? DEFAULT_APP_SETTINGS.geminiModel,
|
||||
useLocalTextEncoder: data.useLocalTextEncoder ?? DEFAULT_APP_SETTINGS.useLocalTextEncoder,
|
||||
promptCacheSize: data.promptCacheSize ?? DEFAULT_APP_SETTINGS.promptCacheSize,
|
||||
promptEnhancerEnabledT2V: data.promptEnhancerEnabledT2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledT2V,
|
||||
promptEnhancerEnabledI2V: data.promptEnhancerEnabledI2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledI2V,
|
||||
promptEnhancerProviderPreference: data.promptEnhancerProviderPreference ?? DEFAULT_APP_SETTINGS.promptEnhancerProviderPreference,
|
||||
seedLocked: data.seedLocked ?? DEFAULT_APP_SETTINGS.seedLocked,
|
||||
lockedSeed: data.lockedSeed ?? DEFAULT_APP_SETTINGS.lockedSeed,
|
||||
modelsDir: data.modelsDir ?? DEFAULT_APP_SETTINGS.modelsDir,
|
||||
useConvVae: data.useConvVae ?? DEFAULT_APP_SETTINGS.useConvVae,
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimePolicyPayload = ApiSuccessOf<'getRuntimePolicy'>
|
||||
type GpuInfoPayload = ApiSuccessOf<'getGpuInfo'>
|
||||
|
||||
export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_APP_SETTINGS)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [runtimePolicyLoaded, setRuntimePolicyLoaded] = useState(false)
|
||||
const [forceApiGenerations, setForceApiGenerations] = useState(true)
|
||||
const [cudaAvailable, setCudaAvailable] = useState(false)
|
||||
const [backendProcessStatus, setBackendProcessStatus] = useState<BackendProcessStatus | null>(null)
|
||||
const [modelsVersion, setModelsVersion] = useState(0)
|
||||
const [settingsRevision, setSettingsRevision] = useState(0)
|
||||
|
||||
const notifyModelsChanged = useCallback(() => {
|
||||
setModelsVersion((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
setRuntimePolicyLoaded(false)
|
||||
|
||||
const fetchRuntimePolicy = async () => {
|
||||
const result = await ApiClient.getRuntimePolicy()
|
||||
if (!result.ok) {
|
||||
if (!cancelled) {
|
||||
// Fail closed until policy can be read.
|
||||
setForceApiGenerations(true)
|
||||
setRuntimePolicyLoaded(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data as RuntimePolicyPayload
|
||||
if (typeof payload.force_api_generations !== 'boolean') {
|
||||
if (!cancelled) {
|
||||
setForceApiGenerations(true)
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setForceApiGenerations(payload.force_api_generations)
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setRuntimePolicyLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchRuntimePolicy()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendProcessStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const fetchGpuInfo = async () => {
|
||||
const result = await ApiClient.getGpuInfo()
|
||||
if (!result.ok || cancelled) return
|
||||
|
||||
const payload = result.data as GpuInfoPayload
|
||||
setCudaAvailable(Boolean(payload.cuda_available))
|
||||
}
|
||||
|
||||
void fetchGpuInfo()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendProcessStatus, modelsVersion])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const applyStatus = (value: unknown) => {
|
||||
const nextStatus = toBackendProcessStatus(value)
|
||||
if (!nextStatus || cancelled) {
|
||||
return
|
||||
}
|
||||
if (nextStatus === 'alive') {
|
||||
resetBackendCredentials()
|
||||
}
|
||||
setBackendProcessStatus(nextStatus)
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onBackendHealthStatus((data) => {
|
||||
applyStatus(data)
|
||||
})
|
||||
|
||||
void window.electronAPI.getBackendHealthStatus()
|
||||
.then((snapshot) => {
|
||||
applyStatus(snapshot)
|
||||
})
|
||||
.catch(() => {
|
||||
// Snapshot is optional at startup; subscription continues to listen for pushes.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshSettings = useCallback(async () => {
|
||||
const result = await ApiClient.getSettings()
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
setSettings(normalizeAppSettings(result.data))
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded || backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
await refreshSettings()
|
||||
if (cancelled) return
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
retryTimer = setTimeout(fetchSettings, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSettings()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
}
|
||||
}, [backendProcessStatus, isLoaded, refreshSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || backendProcessStatus !== 'alive' || settingsRevision === 0) return
|
||||
const syncTimer = setTimeout(async () => {
|
||||
const { hasLtxApiKey: _a, hasFalApiKey: _b, hasGeminiApiKey: _c, modelsDir: _d, ...syncPayload } = settings
|
||||
const result = await ApiClient.updateSettings(syncPayload)
|
||||
if (!result.ok) {
|
||||
// Best-effort settings sync.
|
||||
}
|
||||
}, 150)
|
||||
return () => clearTimeout(syncTimer)
|
||||
}, [backendProcessStatus, isLoaded, settings, settingsRevision])
|
||||
|
||||
const updateSettings = useCallback((patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => {
|
||||
setSettingsRevision(value => value + 1)
|
||||
if (typeof patch === 'function') {
|
||||
setSettings((prev) => patch(prev))
|
||||
return
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, ...patch }))
|
||||
}, [])
|
||||
|
||||
const saveLtxApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ ltxApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const saveGeminiApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ geminiApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const saveFalApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ falApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const shouldVideoGenerateWithLtxApi =
|
||||
forceApiGenerations || (settings.userPrefersLtxApiVideoGenerations && settings.hasLtxApiKey)
|
||||
const shouldImageGenerateWithFalApi =
|
||||
forceApiGenerations || (settings.userPrefersFalApiImageGenerations && settings.hasFalApiKey)
|
||||
|
||||
const contextValue = useMemo<AppSettingsContextValue>(
|
||||
() => ({
|
||||
settings,
|
||||
isLoaded,
|
||||
runtimePolicyLoaded,
|
||||
updateSettings,
|
||||
refreshSettings,
|
||||
saveLtxApiKey,
|
||||
saveFalApiKey,
|
||||
saveGeminiApiKey,
|
||||
forceApiGenerations,
|
||||
shouldVideoGenerateWithLtxApi,
|
||||
shouldImageGenerateWithFalApi,
|
||||
cudaAvailable,
|
||||
modelsVersion,
|
||||
notifyModelsChanged,
|
||||
}),
|
||||
[cudaAvailable, forceApiGenerations, isLoaded, modelsVersion, notifyModelsChanged, refreshSettings, runtimePolicyLoaded, saveFalApiKey, saveGeminiApiKey, saveLtxApiKey, settings, shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi, updateSettings],
|
||||
)
|
||||
|
||||
return <AppSettingsContext.Provider value={contextValue}>{children}</AppSettingsContext.Provider>
|
||||
}
|
||||
|
||||
export function useAppSettings() {
|
||||
const context = useContext(AppSettingsContext)
|
||||
if (!context) {
|
||||
throw new Error('useAppSettings must be used within AppSettingsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// Dev / escape-hatch feature flags. Client-only, per-machine (localStorage) —
|
||||
// not a user setting and not synced. Toggle via the Dev Panel (Ctrl/Cmd+Shift+D).
|
||||
// Adding a feature flag = one entry in DEV_FLAGS; read it from a single gate where
|
||||
// the feature is used (avoid sprinkling the flag across call sites).
|
||||
export type DevFlagKey = 'customIcLora' | 'advancedIcLoraControls' | 'enableMultipleKeyframesVideos'
|
||||
|
||||
interface DevFlagSpec {
|
||||
key: DevFlagKey
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const DEV_FLAGS: DevFlagSpec[] = [
|
||||
{
|
||||
key: 'customIcLora',
|
||||
label: 'Custom IC-LoRA',
|
||||
description: 'Show the "Custom IC-LoRA" conditioning option (user-supplied weights + control video). Off by default — local results are currently low quality. Built-in Canny/Depth IC-LoRA is unaffected.',
|
||||
},
|
||||
{
|
||||
key: 'advancedIcLoraControls',
|
||||
label: 'Advanced IC-LoRA controls',
|
||||
description: 'Expose all IC-LoRA settings (skip stage 2, resolution factor, audio, strengths) for catalog and custom modes. Off by default — settings use the catalog IC-LoRA defaults.',
|
||||
},
|
||||
{
|
||||
key: 'enableMultipleKeyframesVideos',
|
||||
label: 'Multiple keyframe videos',
|
||||
description: 'Show "Generate Multi Keyframes Videos" in the GenSpace mode dropdown (immediately after Generate Videos). Only available for local generations — hidden in API/cloud mode even when this flag is on. Off by default.',
|
||||
},
|
||||
]
|
||||
|
||||
type Flags = Record<DevFlagKey, boolean>
|
||||
|
||||
const DEFAULT_FLAGS: Flags = { customIcLora: false, advancedIcLoraControls: false, enableMultipleKeyframesVideos: false }
|
||||
const STORAGE_KEY = 'ltx-dev-flags'
|
||||
|
||||
function loadFlags(): Flags {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return { ...DEFAULT_FLAGS, ...(JSON.parse(raw) as Partial<Flags>) }
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_FLAGS }
|
||||
}
|
||||
|
||||
interface DevFlagsState {
|
||||
flags: Flags
|
||||
setFlag: (key: DevFlagKey, on: boolean) => void
|
||||
isPanelOpen: boolean
|
||||
setPanelOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DevFlagsContext = createContext<DevFlagsState | null>(null)
|
||||
|
||||
export function DevFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [flags, setFlags] = useState<Flags>(() => loadFlags())
|
||||
const [isPanelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
const setFlag = useCallback((key: DevFlagKey, on: boolean) => {
|
||||
setFlags(prev => {
|
||||
const next = { ...prev, [key]: on }
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* ignore */ }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Global toggle: Ctrl/Cmd + Shift + D. Capture phase so component-level keydown
|
||||
// handlers don't swallow it; preventDefault avoids any Electron/Chromium default.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setPanelOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKey, { capture: true })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DevFlagsContext.Provider value={{ flags, setFlag, isPanelOpen, setPanelOpen }}>
|
||||
{children}
|
||||
</DevFlagsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDevFlags(): DevFlagsState {
|
||||
const ctx = useContext(DevFlagsContext)
|
||||
if (!ctx) throw new Error('useDevFlags must be used within DevFlagsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
KeyboardLayout,
|
||||
KeyboardPreset,
|
||||
BUILT_IN_PRESETS,
|
||||
LTX_DEFAULT_LAYOUT,
|
||||
cloneLayout,
|
||||
ActionId,
|
||||
} from '../lib/keyboard-shortcuts'
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
// Current active layout
|
||||
activeLayout: KeyboardLayout
|
||||
activePresetId: string
|
||||
// All presets (built-in + user)
|
||||
presets: KeyboardPreset[]
|
||||
// Actions
|
||||
switchPreset: (presetId: string) => void
|
||||
updateBinding: (actionId: ActionId, combos: KeyboardLayout[ActionId]) => void
|
||||
resetToPreset: (presetId: string) => void
|
||||
saveAsCustomPreset: (name: string) => void
|
||||
deleteCustomPreset: (presetId: string) => void
|
||||
// Whether the shortcuts editor modal is open
|
||||
isEditorOpen: boolean
|
||||
setEditorOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsState | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'ltx-keyboard-shortcuts'
|
||||
|
||||
interface PersistedState {
|
||||
activePresetId: string
|
||||
customLayout?: KeyboardLayout // Only stored when user has modified bindings
|
||||
customPresets?: KeyboardPreset[]
|
||||
}
|
||||
|
||||
function loadFromStorage(): PersistedState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function saveToStorage(state: PersistedState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
|
||||
const stored = useRef(loadFromStorage())
|
||||
|
||||
const [activePresetId, setActivePresetId] = useState<string>(stored.current?.activePresetId || 'ltx-default')
|
||||
const [customLayout, setCustomLayout] = useState<KeyboardLayout | null>(stored.current?.customLayout || null)
|
||||
const [customPresets, setCustomPresets] = useState<KeyboardPreset[]>(stored.current?.customPresets || [])
|
||||
const [isEditorOpen, setEditorOpen] = useState(false)
|
||||
|
||||
// Resolve active layout: if customLayout is set, use it; otherwise use the preset's layout
|
||||
const activeLayout: KeyboardLayout = customLayout
|
||||
|| [...BUILT_IN_PRESETS, ...customPresets].find(p => p.id === activePresetId)?.layout
|
||||
|| LTX_DEFAULT_LAYOUT
|
||||
|
||||
// Keep a ref to the active layout so updateBinding always reads the latest
|
||||
const activeLayoutRef = useRef(activeLayout)
|
||||
activeLayoutRef.current = activeLayout
|
||||
|
||||
const allPresets = [...BUILT_IN_PRESETS, ...customPresets]
|
||||
|
||||
// Persist whenever state changes
|
||||
useEffect(() => {
|
||||
saveToStorage({
|
||||
activePresetId,
|
||||
customLayout: customLayout || undefined,
|
||||
customPresets: customPresets.length > 0 ? customPresets : undefined,
|
||||
})
|
||||
}, [activePresetId, customLayout, customPresets])
|
||||
|
||||
const switchPreset = useCallback((presetId: string) => {
|
||||
setActivePresetId(presetId)
|
||||
setCustomLayout(null) // Clear any custom modifications — use the preset directly
|
||||
}, [])
|
||||
|
||||
const updateBinding = useCallback((actionId: ActionId, combos: KeyboardLayout[ActionId]) => {
|
||||
// Always read the LATEST layout from the ref to avoid stale closure issues
|
||||
setCustomLayout(prev => {
|
||||
const base = prev || cloneLayout(activeLayoutRef.current)
|
||||
const updated = { ...base, [actionId]: combos }
|
||||
return updated
|
||||
})
|
||||
}, []) // No deps needed — reads from ref
|
||||
|
||||
const resetToPreset = useCallback((presetId: string) => {
|
||||
setActivePresetId(presetId)
|
||||
setCustomLayout(null)
|
||||
}, [])
|
||||
|
||||
const saveAsCustomPreset = useCallback((name: string) => {
|
||||
const preset: KeyboardPreset = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name,
|
||||
description: 'Custom keyboard layout',
|
||||
layout: cloneLayout(activeLayoutRef.current),
|
||||
builtIn: false,
|
||||
}
|
||||
setCustomPresets(prev => [...prev, preset])
|
||||
setActivePresetId(preset.id)
|
||||
setCustomLayout(null)
|
||||
}, [])
|
||||
|
||||
const deleteCustomPreset = useCallback((presetId: string) => {
|
||||
setCustomPresets(prev => prev.filter(p => p.id !== presetId))
|
||||
if (activePresetId === presetId) {
|
||||
setActivePresetId('ltx-default')
|
||||
setCustomLayout(null)
|
||||
}
|
||||
}, [activePresetId])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={{
|
||||
activeLayout,
|
||||
activePresetId,
|
||||
presets: allPresets,
|
||||
switchPreset,
|
||||
updateBinding,
|
||||
resetToPreset,
|
||||
saveAsCustomPreset,
|
||||
deleteCustomPreset,
|
||||
isEditorOpen,
|
||||
setEditorOpen,
|
||||
}}>
|
||||
{children}
|
||||
</KeyboardShortcutsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const ctx = useContext(KeyboardShortcutsContext)
|
||||
if (!ctx) throw new Error('useKeyboardShortcuts must be used inside KeyboardShortcutsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||
import { hasLegacyProjectsEntry } from '../hooks/useProjectReferencesMigration'
|
||||
import { createDefaultTimeline, normalizeProject, type Project, type Asset, type AssetTake, type ProjectTab } from '../types/project-model'
|
||||
import {
|
||||
deleteProjectEntry,
|
||||
readProject,
|
||||
readProjectIds,
|
||||
writeProject,
|
||||
writeProjectIds,
|
||||
} from '../lib/project-storage'
|
||||
|
||||
interface ProjectContextType {
|
||||
currentTab: ProjectTab
|
||||
setCurrentTab: (tab: ProjectTab) => void
|
||||
|
||||
projectIds: string[]
|
||||
activeProject: Project | null
|
||||
getProject: (id: string) => Project | null
|
||||
setProject: (id: string, project: Project) => void
|
||||
createProject: (name: string) => Project
|
||||
deleteProject: (id: string) => void
|
||||
renameProject: (id: string, name: string) => void
|
||||
activateProject: (id: string) => void
|
||||
clearActiveProject: () => void
|
||||
reloadProjectIds: () => void
|
||||
|
||||
addAsset: (projectId: string, asset: Omit<Asset, 'id' | 'createdAt'>) => Asset
|
||||
deleteAsset: (projectId: string, assetId: string) => void
|
||||
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
|
||||
addTakeToAsset: (projectId: string, assetId: string, take: AssetTake) => void
|
||||
deleteTakeFromAsset: (projectId: string, assetId: string, takeIndex: number) => void
|
||||
setAssetActiveTake: (projectId: string, assetId: string, takeIndex: number) => void
|
||||
toggleFavorite: (projectId: string, assetId: string) => void
|
||||
|
||||
genSpaceEditImagePath: string | null
|
||||
setGenSpaceEditImagePath: (path: string | null) => void
|
||||
genSpaceEditMode: 'image' | 'video' | null
|
||||
setGenSpaceEditMode: (mode: 'image' | 'video' | null) => void
|
||||
genSpaceAudioPath: string | null
|
||||
setGenSpaceAudioPath: (path: string | null) => void
|
||||
genSpaceRetakeSource: GenSpaceRetakeSource | null
|
||||
setGenSpaceRetakeSource: (source: GenSpaceRetakeSource | null) => void
|
||||
pendingRetakeUpdate: PendingRetakeUpdate | null
|
||||
setPendingRetakeUpdate: (update: PendingRetakeUpdate | null) => void
|
||||
genSpaceIcLoraSource: GenSpaceIcLoraSource | null
|
||||
setGenSpaceIcLoraSource: (source: GenSpaceIcLoraSource | null) => void
|
||||
pendingIcLoraUpdate: PendingIcLoraUpdate | null
|
||||
setPendingIcLoraUpdate: (update: PendingIcLoraUpdate | null) => void
|
||||
}
|
||||
|
||||
export interface GenSpaceRetakeSource {
|
||||
videoPath: string
|
||||
clipId?: string
|
||||
assetId?: string
|
||||
linkedClipIds?: string[]
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface PendingRetakeUpdate {
|
||||
assetId: string
|
||||
clipIds: string[]
|
||||
newTakeIndex: number
|
||||
}
|
||||
|
||||
export interface GenSpaceIcLoraSource {
|
||||
videoPath: string
|
||||
clipId?: string
|
||||
assetId?: string
|
||||
linkedClipIds?: string[]
|
||||
}
|
||||
|
||||
export interface PendingIcLoraUpdate {
|
||||
assetId: string
|
||||
clipIds: string[]
|
||||
newTakeIndex: number
|
||||
}
|
||||
|
||||
const ProjectContext = createContext<ProjectContextType | null>(null)
|
||||
|
||||
function loadInitialProjectIds(): string[] {
|
||||
if (hasLegacyProjectsEntry()) return []
|
||||
return readProjectIds()
|
||||
}
|
||||
|
||||
export function ProjectProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentTab, setCurrentTab] = useState<ProjectTab>('gen-space')
|
||||
const [projectIds, setProjectIds] = useState<string[]>(() => loadInitialProjectIds())
|
||||
const [activeProject, setActiveProject] = useState<Project | null>(null)
|
||||
const [projectRevision, setProjectRevision] = useState(0)
|
||||
const [genSpaceEditImagePath, setGenSpaceEditImagePath] = useState<string | null>(null)
|
||||
const [genSpaceEditMode, setGenSpaceEditMode] = useState<'image' | 'video' | null>(null)
|
||||
const [genSpaceAudioPath, setGenSpaceAudioPath] = useState<string | null>(null)
|
||||
const [genSpaceRetakeSource, setGenSpaceRetakeSource] = useState<GenSpaceRetakeSource | null>(null)
|
||||
const [pendingRetakeUpdate, setPendingRetakeUpdate] = useState<PendingRetakeUpdate | null>(null)
|
||||
const [genSpaceIcLoraSource, setGenSpaceIcLoraSource] = useState<GenSpaceIcLoraSource | null>(null)
|
||||
const [pendingIcLoraUpdate, setPendingIcLoraUpdate] = useState<PendingIcLoraUpdate | null>(null)
|
||||
|
||||
const bumpProjectRevision = useCallback(() => {
|
||||
setProjectRevision(prev => prev + 1)
|
||||
}, [])
|
||||
|
||||
const getProject = useCallback((id: string): Project | null => readProject(id), [projectRevision])
|
||||
|
||||
const reloadProjectIds = useCallback(() => {
|
||||
const nextProjectIds = hasLegacyProjectsEntry() ? [] : readProjectIds()
|
||||
setProjectIds(nextProjectIds)
|
||||
setActiveProject(prev => (
|
||||
prev && nextProjectIds.includes(prev.id) ? prev : null
|
||||
))
|
||||
bumpProjectRevision()
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const activateProject = useCallback((id: string) => {
|
||||
setActiveProject(readProject(id))
|
||||
}, [])
|
||||
|
||||
const clearActiveProject = useCallback(() => {
|
||||
setActiveProject(null)
|
||||
}, [])
|
||||
|
||||
const persistProject = useCallback((projectId: string, project: Project): Project => {
|
||||
const persistedProject = writeProject(projectId, normalizeProject({ ...project, id: projectId }))
|
||||
setActiveProject(prev => (prev?.id === projectId ? persistedProject : prev))
|
||||
bumpProjectRevision()
|
||||
return persistedProject
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const mutateProject = useCallback((projectId: string, updater: (project: Project) => Project): Project | null => {
|
||||
const project = readProject(projectId)
|
||||
if (!project) return null
|
||||
return persistProject(projectId, updater(project))
|
||||
}, [persistProject])
|
||||
|
||||
const setProject = useCallback((projectId: string, project: Project) => {
|
||||
persistProject(projectId, project)
|
||||
}, [persistProject])
|
||||
|
||||
const createProject = useCallback((name: string): Project => {
|
||||
const defaultTimeline = createDefaultTimeline('Timeline 1')
|
||||
const newProject = normalizeProject({
|
||||
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
assets: [],
|
||||
timelines: [defaultTimeline],
|
||||
activeTimelineId: defaultTimeline.id,
|
||||
})
|
||||
|
||||
const persistedProject = writeProject(newProject.id, newProject)
|
||||
const nextProjectIds = [persistedProject.id, ...readProjectIds().filter(id => id !== persistedProject.id)]
|
||||
writeProjectIds(nextProjectIds)
|
||||
setProjectIds(nextProjectIds)
|
||||
bumpProjectRevision()
|
||||
return persistedProject
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const deleteProject = useCallback((id: string) => {
|
||||
const nextProjectIds = readProjectIds().filter(projectId => projectId !== id)
|
||||
writeProjectIds(nextProjectIds)
|
||||
setProjectIds(nextProjectIds)
|
||||
deleteProjectEntry(id)
|
||||
setActiveProject(prev => (prev?.id === id ? null : prev))
|
||||
bumpProjectRevision()
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const renameProject = useCallback((id: string, name: string) => {
|
||||
mutateProject(id, project => ({
|
||||
...project,
|
||||
name,
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const addAsset = useCallback((projectId: string, assetData: Omit<Asset, 'id' | 'createdAt'>): Asset => {
|
||||
const newAsset: Asset = {
|
||||
...assetData,
|
||||
id: `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: [newAsset, ...project.assets],
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
|
||||
return newAsset
|
||||
}, [mutateProject])
|
||||
|
||||
const deleteAsset = useCallback((projectId: string, assetId: string) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.filter(asset => asset.id !== assetId),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const updateAsset = useCallback((projectId: string, assetId: string, updates: Partial<Asset>) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => (
|
||||
asset.id === assetId ? { ...asset, ...updates } : asset
|
||||
)),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const addTakeToAsset = useCallback((projectId: string, assetId: string, take: AssetTake) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId) return asset
|
||||
|
||||
const existingTakes: AssetTake[] = asset.takes || [{
|
||||
path: asset.path,
|
||||
bigThumbnailPath: asset.bigThumbnailPath,
|
||||
smallThumbnailPath: asset.smallThumbnailPath,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
createdAt: asset.createdAt,
|
||||
}]
|
||||
const newTakes = [...existingTakes, take]
|
||||
const newIndex = newTakes.length - 1
|
||||
|
||||
return {
|
||||
...asset,
|
||||
takes: newTakes,
|
||||
activeTakeIndex: newIndex,
|
||||
path: take.path,
|
||||
bigThumbnailPath: take.bigThumbnailPath,
|
||||
smallThumbnailPath: take.smallThumbnailPath,
|
||||
width: take.width,
|
||||
height: take.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const deleteTakeFromAsset = useCallback((projectId: string, assetId: string, takeIndex: number) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId || !asset.takes || asset.takes.length <= 1) return asset
|
||||
|
||||
const newTakes = asset.takes.filter((_, index) => index !== takeIndex)
|
||||
let newActiveIdx = asset.activeTakeIndex ?? newTakes.length - 1
|
||||
if (newActiveIdx >= newTakes.length) newActiveIdx = newTakes.length - 1
|
||||
if (newActiveIdx < 0) newActiveIdx = 0
|
||||
const activeTake = newTakes[newActiveIdx]
|
||||
|
||||
return {
|
||||
...asset,
|
||||
takes: newTakes,
|
||||
activeTakeIndex: newActiveIdx,
|
||||
path: activeTake.path,
|
||||
bigThumbnailPath: activeTake.bigThumbnailPath,
|
||||
smallThumbnailPath: activeTake.smallThumbnailPath,
|
||||
width: activeTake.width,
|
||||
height: activeTake.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const setAssetActiveTake = useCallback((projectId: string, assetId: string, takeIndex: number) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId || !asset.takes) return asset
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(takeIndex, asset.takes.length - 1))
|
||||
const take = asset.takes[nextIndex]
|
||||
|
||||
return {
|
||||
...asset,
|
||||
activeTakeIndex: nextIndex,
|
||||
path: take.path,
|
||||
bigThumbnailPath: take.bigThumbnailPath,
|
||||
smallThumbnailPath: take.smallThumbnailPath,
|
||||
width: take.width,
|
||||
height: take.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const toggleFavorite = useCallback((projectId: string, assetId: string) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => (
|
||||
asset.id === assetId ? { ...asset, favorite: !asset.favorite } : asset
|
||||
)),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={{
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
projectIds,
|
||||
activeProject,
|
||||
getProject,
|
||||
setProject,
|
||||
createProject,
|
||||
deleteProject,
|
||||
renameProject,
|
||||
activateProject,
|
||||
clearActiveProject,
|
||||
reloadProjectIds,
|
||||
addAsset,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addTakeToAsset,
|
||||
deleteTakeFromAsset,
|
||||
setAssetActiveTake,
|
||||
toggleFavorite,
|
||||
genSpaceEditImagePath,
|
||||
setGenSpaceEditImagePath,
|
||||
genSpaceEditMode,
|
||||
setGenSpaceEditMode,
|
||||
genSpaceAudioPath,
|
||||
setGenSpaceAudioPath,
|
||||
genSpaceRetakeSource,
|
||||
setGenSpaceRetakeSource,
|
||||
pendingRetakeUpdate,
|
||||
setPendingRetakeUpdate,
|
||||
genSpaceIcLoraSource,
|
||||
setGenSpaceIcLoraSource,
|
||||
pendingIcLoraUpdate,
|
||||
setPendingIcLoraUpdate,
|
||||
}}>
|
||||
{children}
|
||||
</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useProjects() {
|
||||
const context = useContext(ProjectContext)
|
||||
if (!context) {
|
||||
throw new Error('useProjects must be used within a ProjectProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ViewType } from '../types/project-model'
|
||||
import { useProjects } from './ProjectContext'
|
||||
|
||||
interface ViewContextType {
|
||||
currentView: ViewType
|
||||
setCurrentView: (view: ViewType) => void
|
||||
openProject: (projectId: string) => void
|
||||
goHome: () => void
|
||||
}
|
||||
|
||||
const ViewContext = createContext<ViewContextType | null>(null)
|
||||
|
||||
export function ViewProvider({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
activeProject,
|
||||
activateProject,
|
||||
clearActiveProject,
|
||||
setCurrentTab,
|
||||
} = useProjects()
|
||||
const [currentView, setCurrentView] = useState<ViewType>('home')
|
||||
|
||||
const openProject = useCallback((projectId: string) => {
|
||||
activateProject(projectId)
|
||||
setCurrentTab('gen-space')
|
||||
setCurrentView('project')
|
||||
}, [activateProject, setCurrentTab])
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
clearActiveProject()
|
||||
setCurrentView('home')
|
||||
}, [clearActiveProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'project' && !activeProject) {
|
||||
setCurrentView('home')
|
||||
}
|
||||
}, [activeProject, currentView])
|
||||
|
||||
return (
|
||||
<ViewContext.Provider value={{ currentView, setCurrentView, openProject, goHome }}>
|
||||
{children}
|
||||
</ViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useView() {
|
||||
const context = useContext(ViewContext)
|
||||
if (!context) {
|
||||
throw new Error('useView must be used within a ViewProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user