import { create } from "zustand"; import type { AddLayerInput, Layer, LayerProps, LayerType, Scene, } from "@/lib/studio-types"; import { DEFAULT_LAYER_SIZE, DEFAULT_SCENE_DURATION, } from "@/lib/studio-types"; import type { BrowserSceneItem } from "@/lib/scene-browser-data"; import { DEFAULT_PX_PER_SECOND, getNextZoomLevel, getProjectDuration, getSceneAtTime, getSceneStartTime, } from "@/lib/studio-timeline"; import { captureHistorySnapshot, cloneScenes, STUDIO_HISTORY_LIMIT, type StudioHistorySnapshot, } from "@/lib/studio-history"; import { contrastTextColor, DEFAULT_SCENE_ACCENT_COLOR, DEFAULT_SCENE_BACKGROUND_COLOR, } from "@/lib/studio-color-palettes"; import { buildVideoSceneDataPayload, parseVideoSceneData, } from "@/lib/studio-scene-data"; import type { SceneTransition } from "@/lib/studio-types"; import { captureSceneThumbnailFromStage, scheduleSceneThumbnailCapture, } from "@/lib/studio-scene-thumbnail"; import { uuid } from "@/lib/uuid"; function createId(): string { return uuid(); } function defaultPropsForType(type: LayerType): LayerProps { switch (type) { case "text": return { text: "Text", fontSize: 48, fill: "#111827", fontFamily: "Inter, sans-serif", align: "left", letterSpacing: 0, lineHeight: 1.2, animation: "none", }; case "image": return { src: "", cornerRadius: 0 }; case "video": return { src: "", fileName: "" }; case "shape": return { shape: "rect", fill: "#2563EB", stroke: "#1E3A8A", strokeWidth: 0, cornerRadius: 0, }; default: return {}; } } function createDefaultScene(name: string): Scene { return { id: createId(), name, duration: DEFAULT_SCENE_DURATION, transitionType: "none", layers: [ { id: createId(), type: "text", x: 240, y: 270, width: 800, height: 100, rotation: 0, opacity: 1, zIndex: 1, props: { text: "Your Title Here", fontSize: 72, fill: "#111827", fontFamily: "Inter, sans-serif", align: "center", bold: true, letterSpacing: 0, lineHeight: 1.2, animation: "none", }, }, { id: createId(), type: "text", x: 290, y: 390, width: 700, height: 60, rotation: 0, opacity: 1, zIndex: 2, props: { text: "Add your subtitle here", fontSize: 32, fill: "#6b7280", fontFamily: "Inter, sans-serif", align: "center", bold: false, letterSpacing: 0, lineHeight: 1.4, animation: "none", }, }, ], }; } function createDefaultLayer(type: LayerType, zIndex: number): Layer { return { id: createId(), type, x: 100, y: 100, width: DEFAULT_LAYER_SIZE.width, height: DEFAULT_LAYER_SIZE.height, rotation: 0, opacity: 1, zIndex, props: defaultPropsForType(type), }; } const initialScene = createDefaultScene("Scene 1"); export interface StudioState { scenes: Scene[]; activeSceneId: string; selectedLayerId: string | null; isPlaying: boolean; currentTime: number; pxPerSecond: number; audioFileName: string | null; audioSrc: string | null; audioVolume: number; sceneBackgroundColor: string; sceneAccentColor: string; /** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). Empty until hydrated. */ chooseMode: string; past: StudioHistorySnapshot[]; future: StudioHistorySnapshot[]; layerClipboard: Layer | null; } export interface StudioActions { addScene: (name?: string) => void; addSceneFromTemplate: (template: BrowserSceneItem) => void; deleteScene: (sceneId: string) => void; duplicateScene: (sceneId: string) => void; reorderScenes: (fromIndex: number, toIndex: number) => void; updateScene: (sceneId: string, updates: Partial) => void; updateSceneThumbnail: (sceneId: string) => void; setActiveScene: (sceneId: string) => void; addLayer: (input: LayerType | AddLayerInput) => void; updateLayer: (layerId: string, updates: Partial) => void; moveLayerToFront: (layerId: string) => void; moveLayerToBack: (layerId: string) => void; deleteLayer: (layerId: string) => void; copyLayer: (layerId: string) => void; pasteLayer: () => void; setSelectedLayer: (layerId: string | null) => void; undo: () => void; redo: () => void; togglePlay: () => void; startPlayback: () => void; stopPlayback: () => void; setCurrentTime: (time: number) => void; setPxPerSecond: (pxPerSecond: number) => void; timelineZoomIn: () => void; timelineZoomOut: () => void; setAudioTrack: (fileName: string, src: string) => void; clearAudioTrack: () => void; setAudioVolume: (volume: number) => void; setSceneBackgroundColor: (color: string) => void; setSceneAccentColor: (color: string) => void; applyPaletteToAllScenes: (mainColor: string, accentColor: string) => void; applyTransitionToAllScenes: (transitionType: SceneTransition) => void; applyFontFamilyToAllTextLayers: (fontFamily: string) => void; hydrateFromSceneData: (sceneData: Record) => boolean; getSceneDataForSave: () => Record; } export type StudioStore = StudioState & StudioActions; function pushHistory( state: StudioState, next: Partial ): Partial { return { past: [...state.past, captureHistorySnapshot(state)].slice( -STUDIO_HISTORY_LIMIT ), future: [], ...next, }; } function updateActiveSceneLayers( scenes: Scene[], activeSceneId: string, updater: (layers: Layer[]) => Layer[] ): Scene[] { return scenes.map((scene) => scene.id !== activeSceneId ? scene : { ...scene, layers: updater(scene.layers) } ); } export const useStudioStore = create((set, get) => { const scheduleActiveSceneThumbnailUpdate = (): void => { const sceneId = get().activeSceneId; scheduleSceneThumbnailCapture(() => { get().updateSceneThumbnail(sceneId); }); }; return { scenes: [initialScene], activeSceneId: initialScene.id, selectedLayerId: null, isPlaying: false, currentTime: 0, pxPerSecond: DEFAULT_PX_PER_SECOND, audioFileName: null, audioSrc: null, audioVolume: 100, sceneBackgroundColor: DEFAULT_SCENE_BACKGROUND_COLOR, sceneAccentColor: DEFAULT_SCENE_ACCENT_COLOR, chooseMode: "", past: [], future: [], layerClipboard: null, addScene: (name) => { const scene = createDefaultScene( name ?? `Scene ${get().scenes.length + 1}` ); set({ scenes: [...get().scenes, scene], activeSceneId: scene.id, selectedLayerId: null, currentTime: 0, isPlaying: false, }); }, addSceneFromTemplate: (template) => { const layers: Layer[] = template.templateLayers.map((tl) => ({ ...tl, id: createId(), })); const scene: Scene = { id: createId(), name: template.name, duration: DEFAULT_SCENE_DURATION, layers, transitionType: "none", }; set({ scenes: [...get().scenes, scene], activeSceneId: scene.id, selectedLayerId: null, currentTime: 0, isPlaying: false, }); }, deleteScene: (sceneId) => { const { scenes, activeSceneId } = get(); if (scenes.length <= 1) return; const nextScenes = scenes.filter((scene) => scene.id !== sceneId); const nextActiveId = activeSceneId === sceneId ? (nextScenes[0]?.id ?? activeSceneId) : activeSceneId; set({ scenes: nextScenes, activeSceneId: nextActiveId, selectedLayerId: null, currentTime: 0, isPlaying: false, }); }, duplicateScene: (sceneId) => { const { scenes } = get(); const source = scenes.find((scene) => scene.id === sceneId); if (!source) return; const copy: Scene = { ...source, id: createId(), name: `${source.name} (copy)`, thumbnailUrl: source.thumbnailUrl, layers: source.layers.map((layer) => ({ ...layer, id: createId(), props: { ...layer.props }, })), }; const index = scenes.findIndex((scene) => scene.id === sceneId); const nextScenes = [...scenes]; nextScenes.splice(index + 1, 0, copy); set({ scenes: nextScenes, activeSceneId: copy.id, selectedLayerId: null, currentTime: 0, isPlaying: false, }); }, reorderScenes: (fromIndex, toIndex) => { const scenes = [...get().scenes]; const [removed] = scenes.splice(fromIndex, 1); if (!removed) return; scenes.splice(toIndex, 0, removed); set({ scenes }); }, updateScene: (sceneId, updates) => { set({ scenes: get().scenes.map((scene) => scene.id === sceneId ? { ...scene, ...updates } : scene ), }); }, updateSceneThumbnail: (sceneId) => { const state = get(); if (state.activeSceneId !== sceneId) return; const thumbnailUrl = captureSceneThumbnailFromStage(); if (!thumbnailUrl) return; set({ scenes: state.scenes.map((scene) => scene.id === sceneId ? { ...scene, thumbnailUrl } : scene ), }); }, setActiveScene: (sceneId) => { set({ activeSceneId: sceneId, selectedLayerId: null, currentTime: getSceneStartTime(get().scenes, sceneId), isPlaying: false, }); }, addLayer: (input) => { const state = get(); const sceneIndex = state.scenes.findIndex( (scene) => scene.id === state.activeSceneId ); if (sceneIndex === -1) return; const config: AddLayerInput = typeof input === "string" ? { type: input } : input; const scene = state.scenes[sceneIndex]; const maxZIndex = scene.layers.reduce( (max, layer) => Math.max(max, layer.zIndex), 0 ); const base = createDefaultLayer(config.type, maxZIndex + 1); const layer: Layer = { ...base, x: config.x ?? base.x, y: config.y ?? base.y, width: config.width ?? base.width, height: config.height ?? base.height, rotation: config.rotation ?? base.rotation, opacity: config.opacity ?? base.opacity, props: { ...base.props, ...config.props }, }; const nextScenes = [...state.scenes]; nextScenes[sceneIndex] = { ...scene, layers: [...scene.layers, layer], }; set( pushHistory(state, { scenes: nextScenes, selectedLayerId: layer.id, }) ); scheduleActiveSceneThumbnailUpdate(); }, updateLayer: (layerId, updates) => { const state = get(); set( pushHistory(state, { scenes: updateActiveSceneLayers( state.scenes, state.activeSceneId, (layers) => layers.map((layer) => layer.id === layerId ? { ...layer, ...updates } : layer ) ), }) ); scheduleActiveSceneThumbnailUpdate(); }, moveLayerToFront: (layerId) => { const state = get(); const scene = getActiveScene(state); if (!scene) return; const maxZIndex = scene.layers.reduce( (max, layer) => Math.max(max, layer.zIndex), 0 ); const target = scene.layers.find((layer) => layer.id === layerId); if (!target || target.zIndex >= maxZIndex) return; set( pushHistory(state, { scenes: updateActiveSceneLayers( state.scenes, state.activeSceneId, (layers) => layers.map((layer) => layer.id === layerId ? { ...layer, zIndex: maxZIndex + 1 } : layer ) ), }) ); }, moveLayerToBack: (layerId) => { const state = get(); const scene = getActiveScene(state); if (!scene) return; const minZIndex = scene.layers.reduce( (min, layer) => Math.min(min, layer.zIndex), 0 ); const target = scene.layers.find((layer) => layer.id === layerId); if (!target || target.zIndex <= minZIndex) return; set( pushHistory(state, { scenes: updateActiveSceneLayers( state.scenes, state.activeSceneId, (layers) => layers.map((layer) => layer.id === layerId ? { ...layer, zIndex: minZIndex - 1 } : layer ) ), }) ); }, deleteLayer: (layerId) => { const state = get(); set( pushHistory(state, { scenes: updateActiveSceneLayers( state.scenes, state.activeSceneId, (layers) => layers.filter((layer) => layer.id !== layerId) ), selectedLayerId: state.selectedLayerId === layerId ? null : state.selectedLayerId, }) ); scheduleActiveSceneThumbnailUpdate(); }, copyLayer: (layerId) => { const scene = getActiveScene(get()); const layer = scene?.layers.find((item) => item.id === layerId); if (!layer) return; set({ layerClipboard: { ...layer, props: { ...layer.props }, }, }); }, pasteLayer: () => { const state = get(); const clip = state.layerClipboard; if (!clip) return; const sceneIndex = state.scenes.findIndex( (scene) => scene.id === state.activeSceneId ); if (sceneIndex === -1) return; const scene = state.scenes[sceneIndex]; const maxZIndex = scene.layers.reduce( (max, layer) => Math.max(max, layer.zIndex), 0 ); const layer: Layer = { ...clip, id: createId(), x: clip.x + 24, y: clip.y + 24, zIndex: maxZIndex + 1, props: { ...clip.props }, }; const nextScenes = [...state.scenes]; nextScenes[sceneIndex] = { ...scene, layers: [...scene.layers, layer], }; set( pushHistory(state, { scenes: nextScenes, selectedLayerId: layer.id, }) ); }, setSelectedLayer: (layerId) => set({ selectedLayerId: layerId }), undo: () => { const state = get(); if (state.past.length === 0) return; const previous = state.past[state.past.length - 1]; const current = captureHistorySnapshot(state); set({ ...previous, scenes: cloneScenes(previous.scenes), past: state.past.slice(0, -1), future: [current, ...state.future].slice(0, STUDIO_HISTORY_LIMIT), }); }, redo: () => { const state = get(); if (state.future.length === 0) return; const next = state.future[0]; const current = captureHistorySnapshot(state); set({ ...next, scenes: cloneScenes(next.scenes), past: [...state.past, current].slice(-STUDIO_HISTORY_LIMIT), future: state.future.slice(1), }); }, togglePlay: () => set((state) => ({ isPlaying: !state.isPlaying })), startPlayback: () => { const state = get(); const max = getProjectDuration(state.scenes); const time = state.currentTime >= max ? 0 : state.currentTime; const scene = getSceneAtTime(state.scenes, time); set({ isPlaying: true, currentTime: time, activeSceneId: scene?.id ?? state.activeSceneId, selectedLayerId: null, }); }, stopPlayback: () => set({ isPlaying: false }), setCurrentTime: (time) => { const max = getProjectDuration(get().scenes); set({ currentTime: Math.min(Math.max(0, time), max) }); }, setPxPerSecond: (pxPerSecond) => set({ pxPerSecond }), timelineZoomIn: () => { set({ pxPerSecond: getNextZoomLevel(get().pxPerSecond, "in") }); }, timelineZoomOut: () => { set({ pxPerSecond: getNextZoomLevel(get().pxPerSecond, "out") }); }, setAudioTrack: (fileName, src) => set({ audioFileName: fileName, audioSrc: src }), clearAudioTrack: () => set({ audioFileName: null, audioSrc: null, audioVolume: 100 }), setAudioVolume: (volume) => set({ audioVolume: Math.min(100, Math.max(0, volume)) }), setSceneBackgroundColor: (color) => set({ sceneBackgroundColor: color }), setSceneAccentColor: (color) => set({ sceneAccentColor: color }), applyPaletteToAllScenes: (mainColor, accentColor) => { const state = get(); // Derive a readable text colour from the main background const textColor = contrastTextColor(mainColor); // A shape is the "full-canvas background" when it fills almost the entire stage const isCanvasBg = (layer: Layer) => layer.type === "shape" && layer.zIndex === 0 && layer.x <= 10 && layer.y <= 10 && layer.width >= 1200 && layer.height >= 600; // A shape is a dark overlay when it has low opacity and sits above the bg const isDarkOverlay = (layer: Layer) => layer.type === "shape" && layer.zIndex >= 2 && layer.opacity <= 0.65; const nextScenes = state.scenes.map((scene) => ({ ...scene, layers: scene.layers.map((layer) => { if (isCanvasBg(layer)) { // Main background → use mainColor return { ...layer, props: { ...layer.props, fill: mainColor, stroke: mainColor, }, }; } if (isDarkOverlay(layer)) { // Keep the overlay as-is — just ensure it stays dark return layer; } if (layer.type === "shape") { // Accent shapes → use accentColor return { ...layer, props: { ...layer.props, fill: accentColor, stroke: accentColor, }, }; } if (layer.type === "text") { // Text → auto-contrast against the main background return { ...layer, props: { ...layer.props, fill: textColor, }, }; } return layer; }), })); set( pushHistory(state, { scenes: nextScenes, sceneBackgroundColor: mainColor, sceneAccentColor: accentColor, }) ); scheduleActiveSceneThumbnailUpdate(); }, applyTransitionToAllScenes: (transitionType) => { set({ scenes: get().scenes.map((scene) => ({ ...scene, transitionType })), }); }, applyFontFamilyToAllTextLayers: (fontFamily) => { const state = get(); const nextScenes = state.scenes.map((scene) => ({ ...scene, layers: scene.layers.map((layer) => layer.type !== "text" ? layer : { ...layer, props: { ...layer.props, fontFamily }, } ), })); set( pushHistory(state, { scenes: nextScenes, }) ); scheduleActiveSceneThumbnailUpdate(); }, hydrateFromSceneData: (sceneData) => { const parsed = parseVideoSceneData(sceneData); if (!parsed) return false; set({ scenes: parsed.scenes, activeSceneId: parsed.activeSceneId, selectedLayerId: null, isPlaying: false, currentTime: parsed.currentTime ?? 0, ...(parsed.pxPerSecond !== undefined ? { pxPerSecond: parsed.pxPerSecond } : {}), audioFileName: parsed.audioFileName ?? null, audioSrc: parsed.audioSrc ?? null, audioVolume: parsed.audioVolume ?? 100, sceneBackgroundColor: parsed.sceneBackgroundColor ?? DEFAULT_SCENE_BACKGROUND_COLOR, sceneAccentColor: parsed.sceneAccentColor ?? DEFAULT_SCENE_ACCENT_COLOR, chooseMode: parsed.chooseMode ?? "", past: [], future: [], }); return true; }, getSceneDataForSave: () => { const state = get(); return buildVideoSceneDataPayload({ scenes: state.scenes, activeSceneId: state.activeSceneId, currentTime: state.currentTime, pxPerSecond: state.pxPerSecond, audioFileName: state.audioFileName, audioSrc: state.audioSrc, audioVolume: state.audioVolume, sceneBackgroundColor: state.sceneBackgroundColor, sceneAccentColor: state.sceneAccentColor, }); }, }; }); export function getActiveScene( state: Pick ): Scene | undefined { return state.scenes.find((scene) => scene.id === state.activeSceneId); } export function getSelectedLayer( state: Pick ): Layer | undefined { const scene = getActiveScene(state); if (!scene || !state.selectedLayerId) return undefined; return scene.layers.find((layer) => layer.id === state.selectedLayerId); }