Files
flatrender/src/lib/studio-store.ts
T
soroush.asadi d56bcf1b23
Build backend images / build content-svc (push) Failing after 57s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 54s
Build backend images / build identity-svc (push) Failing after 1m0s
Build backend images / build notification-svc (push) Failing after 47s
Build backend images / build render-svc (push) Failing after 53s
Build backend images / build studio-svc (push) Failing after 57s
feat(#42): FIX projects can't add scenes (studio + admin)
Template copy now carries choose_mode from the content project → studio store gets
chooseMode; AddSceneMenu returns null for FIX/MusicVisualizer. Admin ProjectScenes
hides '+ صحنهٔ جدید' (shows an 'scenes defined in AE' note) for fixed modes. Verified
choose_mode=FIX flows end-to-end. (Visible admin nav link added earlier.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 05:03:46 +03:30

800 lines
20 KiB
TypeScript

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<Scene>) => void;
updateSceneThumbnail: (sceneId: string) => void;
setActiveScene: (sceneId: string) => void;
addLayer: (input: LayerType | AddLayerInput) => void;
updateLayer: (layerId: string, updates: Partial<Layer>) => 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<string, unknown>) => boolean;
getSceneDataForSave: () => Record<string, unknown>;
}
export type StudioStore = StudioState & StudioActions;
function pushHistory(
state: StudioState,
next: Partial<StudioState>
): Partial<StudioState> {
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<StudioStore>((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<StudioState, "scenes" | "activeSceneId">
): Scene | undefined {
return state.scenes.find((scene) => scene.id === state.activeSceneId);
}
export function getSelectedLayer(
state: Pick<StudioState, "scenes" | "activeSceneId" | "selectedLayerId">
): Layer | undefined {
const scene = getActiveScene(state);
if (!scene || !state.selectedLayerId) return undefined;
return scene.layers.find((layer) => layer.id === state.selectedLayerId);
}