Files
flatrender/src/hooks/useStudioProjectPersistence.ts
T
soroush.asadi c0d04fa855 feat(studio+render): wire theme picker → saved_shared_colors → FlexStory render
Closes the theme→render gap: the studio theme picker now actually drives a
FlexStory render's colours. GetFlexStoryProps reads saved_shared_colors by
element_key (accentColor/secondaryColor/backgroundColor/textColor), but the studio
only wrote the theme into scene_data — so the picker never reached the MP4.

- studio-svc: UpdateSharedColorsAsync upserts saved_shared_colors by (project,
  element_key) + PATCH /v1/saved-projects/{id}/shared-colors endpoint +
  UpdateColorsRequest/UpdateColorItem. Mirrors UpdateSceneContentsAsync. (dotnet
  build: 0 errors.)
- gateway already wildcard-routes /v1/saved-projects/*path → studio-svc (no change).
- Next: /api/projects/[id]/colors route → gateway; project-api patchProjectColors
  + themeColorsFromSceneData (maps scene_data sceneAccentColor… → the colorSchema
  keys); performSave best-effort pushes the 4 colours alongside contents.

Chain: theme picker → store → scene_data → performSave → patchProjectColors →
gateway → studio-svc upsert → saved_shared_colors → GetFlexStoryProps → render.
Verified: Next build + dotnet build both clean; theme presets render cohesively
across all 6 (incl. dark Midnight). End-to-end studio→render needs the live stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:04:47 +03:30

260 lines
7.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import {
isDevelopmentEnv,
loadLocalProject,
saveLocalProject,
} from "@/lib/dev-project-storage";
import {
contentValuesFromSceneData,
fetchProject,
isProjectNotFoundError,
patchProjectColors,
patchProjectContents,
patchProjectSceneData,
themeColorsFromSceneData,
} from "@/lib/project-api";
import { isDevProjectId } from "@/lib/project-ids";
import {
PROJECT_SAVE_DEBOUNCE_MS,
PROJECT_SAVED_DISPLAY_MS,
type ProjectSaveStatus,
} from "@/lib/project-save-status";
import { isVideoSceneDataEmpty } from "@/lib/studio-scene-data";
import { useStudioStore } from "@/lib/studio-store";
export interface UseStudioProjectPersistenceResult {
projectName: string;
setProjectName: (name: string) => void;
saveStatus: ProjectSaveStatus;
usingLocalStorage: boolean;
retrySave: () => void;
}
function applyLocalSnapshot(
projectId: string,
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean,
setProjectName: (name: string) => void
): string | null {
const local = loadLocalProject(projectId);
if (!local) return null;
if (local.name) {
setProjectName(local.name);
}
if (!isVideoSceneDataEmpty(local.scene_data)) {
hydrateFromSceneData(local.scene_data);
}
return JSON.stringify(local.scene_data);
}
export function useStudioProjectPersistence(
projectId: string
): UseStudioProjectPersistenceResult {
const scenes = useStudioStore((state) => state.scenes);
const activeSceneId = useStudioStore((state) => state.activeSceneId);
const currentTime = useStudioStore((state) => state.currentTime);
const pxPerSecond = useStudioStore((state) => state.pxPerSecond);
const audioFileName = useStudioStore((state) => state.audioFileName);
const audioSrc = useStudioStore((state) => state.audioSrc);
const audioVolume = useStudioStore((state) => state.audioVolume);
const sceneBackgroundColor = useStudioStore(
(state) => state.sceneBackgroundColor
);
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor);
const hydrateFromSceneData = useStudioStore(
(state) => state.hydrateFromSceneData
);
const getSceneDataForSave = useStudioStore((state) => state.getSceneDataForSave);
const [projectName, setProjectName] = useState(
() => `Project ${projectId.slice(0, 8)}`
);
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
const [usingLocalStorage, setUsingLocalStorage] = useState(false);
const skipSaveRef = useRef(true);
const lastSavedPayloadRef = useRef<string | null>(null);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const projectNameRef = useRef(projectName);
useEffect(() => {
projectNameRef.current = projectName;
}, [projectName]);
const persistPayload = useMemo(
() => JSON.stringify(getSceneDataForSave()),
[
scenes,
activeSceneId,
currentTime,
pxPerSecond,
audioFileName,
audioSrc,
audioVolume,
sceneBackgroundColor,
sceneAccentColor,
getSceneDataForSave,
]
);
const debouncedPayload = useDebouncedValue(
persistPayload,
PROJECT_SAVE_DEBOUNCE_MS
);
const clearSavedTimer = useCallback(() => {
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
}, []);
const markSaved = useCallback(
(payloadJson: string, local: boolean) => {
lastSavedPayloadRef.current = payloadJson;
setSaveStatus(local ? "local" : "saved");
clearSavedTimer();
savedTimerRef.current = setTimeout(() => {
setSaveStatus((current) =>
current === "saved" || current === "local" ? "idle" : current
);
}, PROJECT_SAVED_DISPLAY_MS);
},
[clearSavedTimer]
);
const saveToLocalStorage = useCallback(
(payloadJson: string) => {
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
saveLocalProject(projectId, {
scene_data,
name: projectNameRef.current,
});
setUsingLocalStorage(true);
lastSavedPayloadRef.current = payloadJson;
setSaveStatus("local");
clearSavedTimer();
savedTimerRef.current = setTimeout(() => {
setSaveStatus((current) => (current === "local" ? "idle" : current));
}, PROJECT_SAVED_DISPLAY_MS);
},
[projectId, clearSavedTimer]
);
const performSave = useCallback(
async (payloadJson: string) => {
if (isDevProjectId(projectId)) return;
setSaveStatus("saving");
try {
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
await patchProjectSceneData(projectId, scene_data);
// Persist the per-input values to content elements so the render binder uses
// them (best-effort — never block the main save on it).
void patchProjectContents(
projectId,
contentValuesFromSceneData(scene_data)
).catch(() => {});
// Persist the theme colours to saved_shared_colors so the FlexStory render
// reads the theme picker (best-effort).
void patchProjectColors(
projectId,
themeColorsFromSceneData(scene_data)
).catch(() => {});
setUsingLocalStorage(false);
markSaved(payloadJson, false);
} catch (error) {
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
saveToLocalStorage(payloadJson);
return;
}
setSaveStatus("error");
}
},
[projectId, markSaved, saveToLocalStorage]
);
const retrySave = useCallback(() => {
void performSave(persistPayload);
}, [performSave, persistPayload]);
useEffect(() => {
if (isDevProjectId(projectId)) return;
let cancelled = false;
skipSaveRef.current = true;
void (async () => {
try {
const { project } = await fetchProject(projectId);
if (cancelled) return;
setUsingLocalStorage(false);
setProjectName(project.name);
if (!isVideoSceneDataEmpty(project.scene_data)) {
hydrateFromSceneData(project.scene_data);
}
lastSavedPayloadRef.current = JSON.stringify(
useStudioStore.getState().getSceneDataForSave()
);
} catch (error) {
if (cancelled) return;
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
const savedPayload = applyLocalSnapshot(
projectId,
hydrateFromSceneData,
setProjectName
);
if (savedPayload) {
setUsingLocalStorage(true);
lastSavedPayloadRef.current = JSON.stringify(
useStudioStore.getState().getSceneDataForSave()
);
}
} else {
setSaveStatus("error");
}
} finally {
if (!cancelled) {
skipSaveRef.current = false;
}
}
})();
return () => {
cancelled = true;
};
}, [projectId, hydrateFromSceneData]);
useEffect(() => {
if (isDevProjectId(projectId) || skipSaveRef.current) return;
if (persistPayload === lastSavedPayloadRef.current) return;
setSaveStatus("pending");
}, [projectId, persistPayload]);
useEffect(() => {
if (isDevProjectId(projectId) || skipSaveRef.current) return;
if (debouncedPayload === lastSavedPayloadRef.current) return;
void performSave(debouncedPayload);
}, [projectId, debouncedPayload, performSave]);
useEffect(() => clearSavedTimer, [clearSavedTimer]);
return {
projectName,
setProjectName,
saveStatus,
usingLocalStorage,
retrySave,
};
}