Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+329
View File
@@ -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
}
+92
View File
@@ -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
}
+348
View File
@@ -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
}
+59
View File
@@ -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
}