Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions docs/development/SLICE_QUEUE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ recording, Video Editor screenshot/export, comic animatic.

Sidecar failure: Hunyuan3D and Rig keep the GLB when provenance write fails.

`useStore` slices (facade kept): theme, settings, developerMode, sidebar,
retake dialog. `openModelVisibility` still lives in `useStore` because it
writes settings fields. `developerModeSlice` still writes `mediaFilter` when
leaving `auditdev`.
`useStore` slices (facade kept): theme, settings (includes model-visibility
focus), developerMode, sidebar, retake dialog. Slices bind through
`bindSlice` without `as never`. `developerModeSlice` no longer writes
`mediaFilter`; the facade still leaves `auditdev` when developer mode turns
off.

Story Lab: `relationships` and `world` tabs extracted. `StoryWorldTab` is an
intermediate cut (too many props; `LocationEditor` / `ReferenceGallery` still
Expand All @@ -38,12 +39,10 @@ namespace) + Assets catalog list chrome.

## Next medium PRs

1. **Domain provenance contract** (this track): `workspace_id` vs
`output_folder`, `GenerationProvenance` / `CommandContext`, initiator vs
provider/model, uniform sidecar-failure helper, Extra info timing keys.
2. **Typed Zustand composition**: drop `as never`, typed slice merge, stop
cross-slice writes (`developerMode` → `mediaFilter`), extract 2–3 *related*
areas together.
1. **Domain provenance contract** — landed (#86).
2. **Typed Zustand composition** (this track): `bindSlice`, no `as never` at
compose time, settings owns model-visibility focus, developerMode does not
write `mediaFilter`.
3. **Story Lab simple tabs**: extract shared `ReferenceGallery` /
`LocationEditor` / controllers first, then Characters + Structure (and other
purely visual tabs) in one PR, with i18n and tests. Do not pass 16 props.
Expand Down
34 changes: 8 additions & 26 deletions ui/src/stores/developerModeSlice.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,15 @@
import { loadDeveloperMode, saveDeveloperMode } from '../lib/developerMode'
import type { MediaFilter } from '../types'
import type { SliceCreator } from './storeApi'

export type DeveloperModeSlice = {
developerMode: boolean
setDeveloperMode: (enabled: boolean) => void
}

// Runtime get() is the full AppState. The setter may also write mediaFilter
// when leaving auditdev; that field stays owned by the gallery store.
type DeveloperModeWriteState = DeveloperModeSlice & {
mediaFilter?: MediaFilter
}

type SetDeveloperModeState = (
partial: Partial<DeveloperModeWriteState> | ((state: DeveloperModeWriteState) => Partial<DeveloperModeWriteState>),
) => void

type GetDeveloperModeState = () => DeveloperModeWriteState

export function createDeveloperModeSlice(set: SetDeveloperModeState, get: GetDeveloperModeState): DeveloperModeSlice {
return {
developerMode: loadDeveloperMode(),
setDeveloperMode: enabled => {
saveDeveloperMode(enabled)
const { mediaFilter } = get()
set({
developerMode: enabled,
mediaFilter: !enabled && mediaFilter === 'auditdev' ? 'all' : mediaFilter,
})
},
}
}
export const createDeveloperModeSlice: SliceCreator<DeveloperModeSlice> = set => ({
developerMode: loadDeveloperMode(),
setDeveloperMode: enabled => {
saveDeveloperMode(enabled)
set({ developerMode: enabled })
},
})
8 changes: 2 additions & 6 deletions ui/src/stores/directorSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
SpeakerMapping,
} from '../types'
import { DEFAULT_DIRECT_VIDEO_MASTER_PROMPT } from '../types'
import type { SliceCreator } from './storeApi'

export type DirectorSlice = {
directorStep: 'upload' | 'analyze' | 'structure' | 'style' | 'plan' | 'review' | 'generate_images' | 'plan_video' | 'review_video'
Expand Down Expand Up @@ -97,11 +98,6 @@ export type DirectorSlice = {
directorAllowClipText: boolean
}

export type DirectorState = DirectorSlice & { directorSeamless: boolean }
type SetDirectorState = (
partial: Partial<DirectorState> | ((state: DirectorState) => Partial<DirectorState>),
) => void

const defaultTreatment = (): MusicVideoTreatment => ({
generation_mode: 'image_guided',
direct_video_master_prompt: DEFAULT_DIRECT_VIDEO_MASTER_PROMPT,
Expand All @@ -122,7 +118,7 @@ const defaultTreatment = (): MusicVideoTreatment => ({
* Director's state and local reducers. Async orchestration remains in the
* store for now; this slice gives it a stable, compatible state boundary.
*/
export function createDirectorSlice(set: SetDirectorState): DirectorSlice {
export const createDirectorSlice: SliceCreator<DirectorSlice> = set => {
return {
directorStep: 'upload',
directorAudioFile: null,
Expand Down
20 changes: 8 additions & 12 deletions ui/src/stores/retakeDialogSlice.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import type { SliceCreator } from './storeApi'

export type RetakeDialogSlice = {
retakeDialogOpen: boolean
retakeSourceFile: string | null
openRetakeDialog: (filename: string) => void
closeRetakeDialog: () => void
}

type SetRetakeDialogState = (
partial: Partial<RetakeDialogSlice> | ((state: RetakeDialogSlice) => Partial<RetakeDialogSlice>),
) => void

export function createRetakeDialogSlice(set: SetRetakeDialogState): RetakeDialogSlice {
return {
retakeDialogOpen: false,
retakeSourceFile: null,
openRetakeDialog: filename => set({ retakeDialogOpen: true, retakeSourceFile: filename }),
closeRetakeDialog: () => set({ retakeDialogOpen: false, retakeSourceFile: null }),
}
}
export const createRetakeDialogSlice: SliceCreator<RetakeDialogSlice> = set => ({
retakeDialogOpen: false,
retakeSourceFile: null,
openRetakeDialog: filename => set({ retakeDialogOpen: true, retakeSourceFile: filename }),
closeRetakeDialog: () => set({ retakeDialogOpen: false, retakeSourceFile: null }),
})
33 changes: 18 additions & 15 deletions ui/src/stores/settingsSlice.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import type { SettingsTab } from '../types'
import type { GenerationMode, SettingsTab } from '../types'
import type { SliceCreator } from './storeApi'

export type SettingsSlice = {
settingsOpen: boolean
toggleSettings: () => void
setSettingsOpen: (open: boolean) => void
settingsTab: SettingsTab
setSettingsTab: (tab: SettingsTab) => void
modelVisibilityFocus: GenerationMode | null
openModelVisibility: (mode: GenerationMode) => void
clearModelVisibilityFocus: () => void
}

type SetSettingsState = (
partial: Partial<SettingsSlice> | ((state: SettingsSlice) => Partial<SettingsSlice>),
) => void

type GetSettingsState = () => SettingsSlice

export function createSettingsSlice(set: SetSettingsState, get: GetSettingsState): SettingsSlice {
return {
settingsOpen: false,
toggleSettings: () => set({ settingsOpen: !get().settingsOpen }),
setSettingsOpen: open => set({ settingsOpen: open }),
export const createSettingsSlice: SliceCreator<SettingsSlice> = (set, get) => ({
settingsOpen: false,
toggleSettings: () => set({ settingsOpen: !get().settingsOpen }),
setSettingsOpen: open => set({ settingsOpen: open }),
settingsTab: 'performance',
setSettingsTab: tab => set({ settingsTab: tab }),
modelVisibilityFocus: null,
openModelVisibility: mode => set({
settingsOpen: true,
settingsTab: 'performance',
setSettingsTab: tab => set({ settingsTab: tab }),
}
}
modelVisibilityFocus: mode,
}),
clearModelVisibilityFocus: () => set({ modelVisibilityFocus: null }),
})
20 changes: 7 additions & 13 deletions ui/src/stores/sidebarSlice.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import type { SliceCreator } from './storeApi'

export type SidebarSlice = {
sidebarOpen: boolean
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
}

type SetSidebarState = (
partial: Partial<SidebarSlice> | ((state: SidebarSlice) => Partial<SidebarSlice>),
) => void

type GetSidebarState = () => SidebarSlice

export function createSidebarSlice(set: SetSidebarState, get: GetSidebarState): SidebarSlice {
return {
sidebarOpen: false,
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: open => set({ sidebarOpen: open }),
}
}
export const createSidebarSlice: SliceCreator<SidebarSlice> = (set, get) => ({
sidebarOpen: false,
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: open => set({ sidebarOpen: open }),
})
28 changes: 28 additions & 0 deletions ui/src/stores/storeApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Typed Zustand slice binding so composers do not use `as never`. */

export type SliceSet<T> = (
partial: Partial<T> | ((state: T) => Partial<T>),
) => void

export type SliceGet<T> = () => T

export type SliceCreator<T> = (set: SliceSet<T>, get: SliceGet<T>) => T

/**
* Bind a slice to the full store. `TStore` must include the slice keys, so a
* `Partial<TSlice>` is a valid store update without `as never`.
*/
export function bindSlice<TSlice extends object, TStore extends TSlice>(
set: SliceSet<TStore>,
get: SliceGet<TStore>,
create: SliceCreator<TSlice>,
): TSlice {
const sliceSet: SliceSet<TSlice> = partial => {
if (typeof partial === 'function') {
set(state => partial(state) as Partial<TStore>)
return
}
set(partial as Partial<TStore>)
}
return create(sliceSet, get)
}
35 changes: 14 additions & 21 deletions ui/src/stores/themeSlice.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
import { applyThemePrefs, getStoredPrefs, type FamilyId, type ThemeMode, type ThemePrefs } from '../lib/theme'
import type { SliceCreator } from './storeApi'

export type ThemeSlice = {
themePrefs: ThemePrefs
setThemeMode: (mode: ThemeMode) => void
setThemeFamily: (family: FamilyId) => void
}

type SetThemeState = (
partial: Partial<ThemeSlice> | ((state: ThemeSlice) => Partial<ThemeSlice>),
) => void

type GetThemeState = () => ThemeSlice

export function createThemeSlice(set: SetThemeState, get: GetThemeState): ThemeSlice {
return {
themePrefs: getStoredPrefs(),
setThemeMode: mode => {
const prefs = { ...get().themePrefs, mode }
applyThemePrefs(prefs)
set({ themePrefs: prefs })
},
setThemeFamily: family => {
const prefs = { ...get().themePrefs, family }
applyThemePrefs(prefs)
set({ themePrefs: prefs })
},
}
}
export const createThemeSlice: SliceCreator<ThemeSlice> = (set, get) => ({
themePrefs: getStoredPrefs(),
setThemeMode: mode => {
const prefs = { ...get().themePrefs, mode }
applyThemePrefs(prefs)
set({ themePrefs: prefs })
},
setThemeFamily: family => {
const prefs = { ...get().themePrefs, family }
applyThemePrefs(prefs)
set({ themePrefs: prefs })
},
})
47 changes: 18 additions & 29 deletions ui/src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createDirectorSlice } from './directorSlice'
import { createRetakeDialogSlice } from './retakeDialogSlice'
import { createSettingsSlice } from './settingsSlice'
import { createSidebarSlice } from './sidebarSlice'
import { bindSlice } from './storeApi'
import { createThemeSlice } from './themeSlice'
import { markJobsCancelling, prependJob, removeJob, updateJob, withJobs } from './jobReducers'
import {
Expand Down Expand Up @@ -2322,25 +2323,21 @@ async function _syncGlobalProductionVideoFormat(
})
}

export const useStore = create<AppState>((set, get) => ({
...createDirectorSlice(partial => set(partial as never)),
...createThemeSlice(
partial => set(partial as never),
() => get(),
),
...createSettingsSlice(
partial => set(partial as never),
() => get(),
),
...createSidebarSlice(
partial => set(partial as never),
() => get(),
),
...createRetakeDialogSlice(partial => set(partial as never)),
...createDeveloperModeSlice(
partial => set(partial as never),
() => get(),
),
export const useStore = create<AppState>((set, get) => {
const developerMode = bindSlice(set, get, createDeveloperModeSlice)
return {
...bindSlice(set, get, createDirectorSlice),
...bindSlice(set, get, createThemeSlice),
...bindSlice(set, get, createSettingsSlice),
...bindSlice(set, get, createSidebarSlice),
...bindSlice(set, get, createRetakeDialogSlice),
...developerMode,
setDeveloperMode: (enabled: boolean) => {
developerMode.setDeveloperMode(enabled)
if (!enabled && get().mediaFilter === 'auditdev') {
get().setMediaFilter('all')
}
},
// Generation mode
generationMode: 'video',
editSubMode: 'retake' as import('../types').EditSubMode,
Expand Down Expand Up @@ -3737,15 +3734,6 @@ export const useStore = create<AppState>((set, get) => ({
return { enabledModels: next }
})
},
// Open Settings → Performance and ask the Enabled Models section to
// expand + scroll to the given mode (fired by the ModelSelector hint).
modelVisibilityFocus: null,
openModelVisibility: (mode) => set({
settingsOpen: true,
settingsTab: 'performance',
modelVisibilityFocus: mode,
}),
clearModelVisibilityFocus: () => set({ modelVisibilityFocus: null }),
loadModels: async () => {
try {
// The backend catalog is the single source for Hunyuan3D models too:
Expand Down Expand Up @@ -10953,4 +10941,5 @@ export const useStore = create<AppState>((set, get) => ({

setTimeout(poll, 1000)
},
}))
}
})
23 changes: 21 additions & 2 deletions ui/tests/architectureSlices.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ test('Director slice keeps the public setter behavior while state is composed se
const partial = typeof update === 'function' ? update(state) : update
state = { ...state, ...partial }
}
state = createDirectorSlice(set)
state = createDirectorSlice(set, () => state)

state.setDirectorMusicVideoTreatment({ generation_mode: 'direct_video' })
assert.equal(state.directorMusicVideoTreatment.generation_mode, 'direct_video')
Expand Down Expand Up @@ -117,6 +117,12 @@ test('settings slice toggles open and tab through the public facade', async () =
assert.equal(useStore.getState().settingsTab, 'integrations')
useStore.getState().toggleSettings()
assert.equal(useStore.getState().settingsOpen, false)
useStore.getState().openModelVisibility('video')
assert.equal(useStore.getState().settingsOpen, true)
assert.equal(useStore.getState().settingsTab, 'performance')
assert.equal(useStore.getState().modelVisibilityFocus, 'video')
useStore.getState().clearModelVisibilityFocus()
assert.equal(useStore.getState().modelVisibilityFocus, null)
})

test('sidebar slice toggles open through the public facade', async () => {
Expand Down Expand Up @@ -147,7 +153,7 @@ test('retake dialog slice opens and closes through the public facade', async ()
const partial = typeof update === 'function' ? update(state) : update
state = { ...state, ...partial }
}
state = createRetakeDialogSlice(set)
state = createRetakeDialogSlice(set, () => state)
assert.equal(state.retakeDialogOpen, false)
assert.equal(state.retakeSourceFile, null)
state.openRetakeDialog('clip.mp4')
Expand Down Expand Up @@ -207,6 +213,19 @@ test('developer-mode slice persists the local flag through the public facade', a
useStore.getState().setDeveloperMode(true)
assert.equal(useStore.getState().developerMode, true)
assert.equal(useStore.getState().mediaFilter, 'videos')

const isolated = createDeveloperModeSlice(set, () => state)
isolated.setDeveloperMode(false)
assert.equal('mediaFilter' in isolated, false)
})

test('composed slices bind without as-never casts at the useStore call site', async () => {
const fs = await import('node:fs/promises')
const source = await fs.readFile(new URL('../src/stores/useStore.ts', import.meta.url), 'utf8')
const composition = source.split('export const useStore')[1].split('generationMode:')[0]
assert.match(composition, /bindSlice\(set, get, createSettingsSlice\)/)
assert.match(composition, /bindSlice\(set, get, createThemeSlice\)/)
assert.doesNotMatch(composition, /as never/)
})

test('useStore keeps Director actions available through its existing public facade', async () => {
Expand Down