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
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>
800 lines
20 KiB
TypeScript
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);
|
|
}
|