349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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
|
|
}
|