Scene - Save Load - v2.1 (#305)

Saving and Loading of Scenes
- Save New scene to server implemented
- Retrieving sceneToken from new scene and refreshes URL, which brings
the editor to state of editing an existing Scene
- Optimized scene reloading when URL changes
- Save Existing scene to server implemented
- Rename scene to server implemented
- Optimized Scene Renaming Component and Endpoint Operation

Editor Changes and Fixes:
- changed the way the api_manager retrieve session tokens
- changed the way api_manager uses a fetcher for the operations in
saving scenes

Co-authored-by: wil <wil@storyteller.ai>
This commit is contained in:
wilandwil authored and GitHub committed 2024-05-03 13:03:06 -04:00
1 parent 17bf313e45
commit ad2677a575
12 files changed
+352 -166

No files matched your search

+6 -1
View File
@@ -25,4 +25,9 @@ export const getMediaFileByToken = (fileToken: string) =>
`${apiHost}/v1/media_files/file/${fileToken}`;
// Upload File
export const uploadMedia = `${apiHost}/v1/media_files/upload`
export const uploadMedia = `${apiHost}/v1/media_files/upload`;
// Scenes
export const uploadNewScene = `${apiHost}/v1/media_files/upload/new_scene`;
export const updateExistingScene = (sceneToken:string)=>`${apiHost}/v1/media_files/upload/saved_scene/${sceneToken}`
export const renameScene = (sceneToken:string)=>`${apiHost}/v1/media_files/rename/${sceneToken}`
+10
View File
@@ -8,10 +8,12 @@ import { signalScene } from "~/store";
import Editor from "~/pages/PageEnigma/js/editor";
interface Props {
sceneToken?: string
children: ReactNode;
}
export const EngineProvider = ({
sceneToken,
children,
}: Props) => {
const [editor, setEditor] = useState<Editor | null>(null);
@@ -35,6 +37,14 @@ export const EngineProvider = ({
// }
}, [dispatchAppUiState, authState]);
useEffect(() => {
if (editor && editor.can_initialize) {
editor.initialize({
sceneToken: sceneToken || ""}
);
}
}, [editor, sceneToken]);
return (
<EngineContext.Provider value={editor}>{children}</EngineContext.Provider>
);
+86
View File
@@ -0,0 +1,86 @@
import { useState, useContext } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSignals } from "@preact/signals-react/runtime";
import { AuthenticationContext } from "~/contexts/Authentication";
import { faSpinnerThird } from '@fortawesome/pro-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { scene, signalScene } from "~/store";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/store";
import { Input } from '~/components';
import { renameScene } from './utilities';
export const SceneTitleInput = ()=>{
useSignals();
const { authState } = useContext(AuthenticationContext);
const [{isValid, isSaving}, setState] = useState<{isValid:boolean; isSaving:boolean}>({isValid:true, isSaving:false});
const setIsValid = (val:boolean)=>{
setState((curr)=>({...curr, isValid:val}));
};
const setIsSaving = (val:boolean)=>{
setState((curr)=>({...curr, isSaving:val}));
};
const handleShowErrorDialog = () => {
errorDialogTitle.value = "Error";
errorDialogMessage.value = "Scene name can not be empty.";
showErrorDialog.value = true;
};
const handleChangeSceneTitle = (e: React.ChangeEvent<HTMLInputElement>)=>{
signalScene({
...scene.value,
title : e.target.value,
});
if (scene.value.title !== "") {
setIsValid(true);
}
}
const validateSceneTitle = (e: React.FocusEvent<HTMLInputElement>)=>{
if (scene.value.title === "") {
setIsValid(false);
handleShowErrorDialog();
e.target.focus();
}else if(scene.value.token && authState.sessionToken){
setIsSaving(true);
renameScene(
scene.value.title!, //guarunteed by input
scene.value.token,
authState.sessionToken,
).then((res)=>{
// console.log(res);
//TODO: HANDLE ERROR
setIsSaving(false);
});
}
};
return(
<div className={twMerge(
"flex gap-3 justify-between items-center",
isSaving && "ml-3"
)}>
<Input
disabled={scene.value.ownerToken !== authState.userInfo?.user_token}
className="-ml-2 w-96"
inputClassName={twMerge(
"bg-ui-panel focus:bg-brand-secondary text-ellipsis",
isSaving ? "outline-brand-secondary" : "focus:ml-3",
)}
isError={!isValid}
value={scene.value.title || ""}
onChange={handleChangeSceneTitle}
onBlur={validateSceneTitle}
/>
{isSaving &&
<FontAwesomeIcon icon={faSpinnerThird} spin/>
}
</div>
);
}
+8 -42
View File
@@ -1,17 +1,10 @@
import { useState } from 'react';
import { useLocation, useParams } from "@remix-run/react";
import { useSignals } from "@preact/signals-react/runtime";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { ButtonLink, Input } from "~/components";
import { AuthButtons } from "./AuthButtons";
import { scene } from "~/store";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/store";
import { MyMoviesButton } from "~/modules/TopBar/MyMoviesButton";
import { useLocation, useParams } from "@remix-run/react";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { ButtonLink } from "~/components";
import { AuthButtons } from "./AuthButtons";
import { SceneTitleInput } from "./SceneTitleInput";
import { MyMoviesButton } from "~/modules/TopBar/MyMoviesButton";
import { getCurrentLocationWithoutParams } from '~/utilities';
function isEditorPath(path:string){
@@ -24,28 +17,8 @@ interface Props {
}
export const TopBar = ({ pageName }: Props) => {
useSignals();
const currentLocation = getCurrentLocationWithoutParams(useLocation().pathname, useParams());
const [isValid, setIsValid] = useState<boolean>(true);
const handleShowErrorDialog = () => {
errorDialogTitle.value = "Error";
errorDialogMessage.value = "Scene name can not be empty.";
showErrorDialog.value = true;
};
const handleChangeSceneTitle = (e: React.ChangeEvent<HTMLInputElement>)=>{
scene.value.title = e.target.value;
if (scene.value.title !== "") {
setIsValid(true);
}
}
const validateSceneTitle = (e: React.FocusEvent<HTMLInputElement>)=>{
if (scene.value.title === "") {
setIsValid(false);
handleShowErrorDialog();
e.target.focus();
}
};
return (
<header className="fixed left-0 top-0 z-10 w-full border-b border-ui-panel-border bg-ui-panel">
<nav
@@ -68,16 +41,9 @@ export const TopBar = ({ pageName }: Props) => {
</div>
<div className="flex items-center justify-center font-medium gap-2">
<span className="opacity-60">{pageName}</span>
<span className="opacity-60 text-nowrap">{pageName}</span>
<span className="opacity-60">/</span>
<Input
className="-ml-2 w-96"
inputClassName="bg-ui-panel focus:bg-brand-secondary focus:ml-3 text-ellipsis"
isError={!isValid}
value={scene.value.title || ""}
onChange={handleChangeSceneTitle}
onBlur={validateSceneTitle}
/>
<SceneTitleInput />
</div>
<div className="flex justify-end gap-2">
+33
View File
@@ -0,0 +1,33 @@
import { renameScene as renameSceneEndpoint } from "~/api";
export const renameScene = async (
sceneTitle: string,
sceneToken: string,
sessionToken: string,
) => {
const endpoint = renameSceneEndpoint(sceneToken);
// console.log(`rename ${sceneToken}: ${sceneTitle}`);
return await fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
session: sessionToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: sceneTitle,
}),
})
.then(res => res.json())
.then(res => {
if (res && res.success) {
// console.log(res);
return res;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { PageEnigmaComponent } from "./PageEnigmaComponent";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { TrackProvider } from "~/pages/PageEnigma/contexts/TrackContext/TrackProvider";
import { DragComponent } from "~/pages/PageEnigma/comps/DragComponent/DragComponent";
import { EngineProvider } from "~/contexts/EngineProvider";
@@ -15,8 +15,8 @@ export const PageEnigma = ({
useInferenceJobManager();
return (
<TrackProvider>
<EngineProvider>
<PageEnigmaComponent sceneToken={sceneToken}/>
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
<GenerateModals />
<ErrorDialog />
@@ -1,24 +0,0 @@
import { useContext, useEffect } from "react";
import { AuthenticationContext } from "~/contexts/Authentication";
import { AppUiContext } from "~/contexts/AppUiContext";
import { EngineContext } from "~/contexts/EngineContext";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { signalScene } from "~/store";
export const PageEnigmaComponent = ({
sceneToken
}: {
sceneToken?: string;
}) => {
const editor = useContext(EngineContext);
useEffect(() => {
if (editor && editor.can_initialize) {
editor.initialize({
sceneToken: sceneToken || ""}
);
}
}, [editor, sceneToken]);
return <PageEditor />;
};
@@ -1,15 +1,19 @@
import { useContext, useState } from "react";
import { useParams, useLocation, useNavigate } from "@remix-run/react";
import { useContext, useState, useCallback } from "react";
import { useParams, useLocation, useNavigate, } from "@remix-run/react";
import { useSignalEffect } from "@preact/signals-react/runtime";
import { scene } from "~/store";
import { scene, signalScene } from "~/store";
import { EngineContext } from "~/contexts/EngineContext";
import { AuthenticationContext } from "~/contexts/Authentication";
import { ToasterContext, ToastTypes } from "~/contexts/ToasterContext";
import {ToasterContext, ToastTypes } from "~/contexts/ToasterContext";
import { faFile } from "@fortawesome/pro-solid-svg-icons";
import { ButtonDropdown, Input } from "~/components";
import {
ButtonDropdown,
Input,
H4,
} from "~/components";
import { ButtonDialogue } from "~/modules/ButtonDialogue";
import { TestFeaturesButtons } from "./TestFeaturesButtons";
import { Help } from "./Help";
@@ -27,7 +31,7 @@ export const ControlsTopButtons = () => {
const {authState} = useContext(AuthenticationContext);
const [sceneTitleInput, setSceneTitleInput] = useState<string>("");
const [sceneToken, setSceneToken] = useState<string>("");
const [sceneTokenSelected, setSceneTokenSelected] = useState<string>("");
const { addToast } = useContext(ToasterContext);
const handleChangeSceneTitleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -37,47 +41,77 @@ export const ControlsTopButtons = () => {
setSceneTitleInput("");
}
const handleButtonNew = ()=>{
editorEngine?.newScene(sceneTitleInput);
};
const handleButtonSave = async () => {
console.log(`SceneName is ${scene.value.title}`);
const sceneMediaToken = await editorEngine?.saveScene(scene.value.title || "");
if (sceneMediaToken) {
addToast(ToastTypes.SUCCESS, sceneMediaToken);
// console.log(`SceneName is ${scene.value.title}`);
const retSceneMediaToken = await editorEngine?.saveScene({
sceneTitle : scene.value.title || "",
sceneToken : scene.value.token
});
if (retSceneMediaToken) {
addToast(ToastTypes.SUCCESS, retSceneMediaToken);
if(!scene.value.token){
signalScene({
...scene.value,
token: retSceneMediaToken
});
}
}
};
const handleButtonNewFromTemplate = () => {
editorEngine?.loadScene(sceneToken)
.catch((err) => {
addToast(ToastTypes.ERROR, err.message);
const handleButtonSaveAsCopy = useCallback(async() => {
const retSceneMediaToken = await editorEngine?.saveScene({
sceneTitle : sceneTitleInput,
sceneToken : undefined
});
if (retSceneMediaToken) {
addToast(ToastTypes.SUCCESS, retSceneMediaToken);
signalScene({
...scene.value,
token: retSceneMediaToken,
ownerToken: authState.userInfo?.user_token,
title: sceneTitleInput,
});
}
}, [sceneTitleInput]);
const handleButtonNewFromTemplate = () => {
handleButtonLoadScene();
};
const handleButtonLoadScene = () => {
editorEngine?.loadScene(sceneToken)
editorEngine?.loadScene(sceneTokenSelected)
.catch((err) => {
addToast(ToastTypes.ERROR, err.message);
});
};
const handleSceneSelection = (token: string) => {
setSceneToken(token);
setSceneTokenSelected(token);
};
useSignalEffect(()=>{
console.log("useSignalEffect in File Buttons");
console.log(scene.value);
if (!scene.value.isInitializing){
setSceneTitleInput(scene.value.title || "");
const currentLocation = getCurrentLocationWithoutParams(location.pathname, params);
if(scene.value.token === undefined){
console.log("delete param from url if it exists");
const currentLocation = getCurrentLocationWithoutParams(location.pathname, params);
console.log(`should navigate to ${currentLocation}`)
navigate(currentLocation);
}
if(scene.value.token && scene.value.token !== params.sceneToken){
console.log("nav to the next param on the url");
const currentLocation = getCurrentLocationWithoutParams(location.pathname, params);
navigate(currentLocation+scene.value.token);
if(params.sceneToken){
//case of create new scene from existing scene
history.pushState({}, "",currentLocation);
}
//case of create new scene from unsaved scene
navigate(currentLocation, {replace:true});
}else if (scene.value.token){
if(params.sceneToken && scene.value.token !== params.sceneToken){
//case of loading existing scene from existing scene
history.pushState({}, "",currentLocation+scene.value.token);
}
//case of loading existing scene from unsaved new scene
//or case of updating existing scene
navigate(currentLocation+scene.value.token, {replace:true});
}
}
});
@@ -108,9 +142,7 @@ export const ControlsTopButtons = () => {
confirmButtonProps: {
label: "Create",
disabled: sceneTitleInput === "",
onClick: () => editorEngine?.newScene(
sceneTitleInput
),
onClick: handleButtonNew,
},
closeButtonProps: {
label: "Cancel",
@@ -129,7 +161,7 @@ export const ControlsTopButtons = () => {
),
confirmButtonProps: {
label: "Create",
disabled: sceneToken === "",
disabled: sceneTokenSelected === "",
onClick: handleButtonNewFromTemplate,
},
closeButtonProps: {
@@ -147,7 +179,7 @@ export const ControlsTopButtons = () => {
content: <LoadScene onSceneSelect={handleSceneSelection} />,
confirmButtonProps: {
label: "Load",
disabled: sceneToken === "",
disabled: sceneTokenSelected === "",
onClick: handleButtonLoadScene,
},
closeButtonProps: {
@@ -165,15 +197,10 @@ export const ControlsTopButtons = () => {
dialogProps: {
title: "Save Scene",
content: (
<Input
value={sceneTitleInput}
label="Please enter a name for your scene"
onChange={handleChangeSceneTitleInput}
/>
<H4>Save scene to <b>{scene.value.title}</b>?</H4>
),
confirmButtonProps: {
label: "Save",
disabled: sceneTitleInput === "",
onClick: handleButtonSave,
},
closeButtonProps: {
@@ -202,7 +229,7 @@ export const ControlsTopButtons = () => {
confirmButtonProps: {
label: "Save",
disabled: sceneTitleInput === "",
onClick: handleButtonSave,
onClick: handleButtonSaveAsCopy,
},
closeButtonProps: {
label: "Cancel",
@@ -30,6 +30,7 @@ export const LoadScene = ({ onSceneSelect }: LoadSceneProps) => {
return;
}
sceneLoading.current = true;
// console.log("load scene");
GetMediaByUser(
authState.userInfo.username,
{},
@@ -38,6 +39,7 @@ export const LoadScene = ({ onSceneSelect }: LoadSceneProps) => {
},
)
.then((res: GetMediaListResponse) => {
// console.log(res);
if (res.success && res.results) {
setScenes(
res.results.map((scene) => ({
+108
View File
@@ -0,0 +1,108 @@
import { v4 as uuidv4 } from "uuid";
import {
uploadNewScene as uploadNewSceneEndpoint,
updateExistingScene as updateExistingSceneEndpoint
} from "~/api";
export const uploadNewScene = async (
file:File,
sceneTitle: string,
sessionToken: string,
) => {
const endpoint = uploadNewSceneEndpoint;
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
formData.append("maybe_title", sceneTitle);
formData.append("maybe_visibility", "public");
formData.append("engine_category", "scene");
return await fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
session: sessionToken,
},
body: formData,
})
.then(res => res.json())
.then(res => {
if (res && res.success) {
return res;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
export const updateExistingScene = async (
file:File,
sceneToken: string,
sessionToken: string,
) => {
const endpoint = updateExistingSceneEndpoint(sceneToken);
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
return await fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
session: sessionToken,
},
body: formData,
})
.then(res => res.json())
.then(res => {
if (res && res.success) {
return res;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
// function uploadEngineAsset(
// file: File,
// media_file_token: string | null,
// ): Promise<any> {
// const url = `${this.baseUrl}/v1/media_files/write/engine_asset`;
// const uuid = uuidv4();
// const form_data = new FormData();
// form_data.append("uuid_idempotency_token", uuid);
// // update existing scene otherwise create new glb scene and use it's media_file_id
// if (media_file_token != null) {
// form_data.append("media_file_token", media_file_token);
// }
// form_data.append("file", file);
// form_data.append("source", "file");
// form_data.append("media_file_subtype", "scene_import");
// form_data.append("media_file_class", "scene");
// const response = await fetch(url, {
// method: "POST",
// // credentials: "include",
// headers: {
// Accept: "application/json",
// session: this.sessionToken,
// },
// body: form_data,
// });
// if (!response.ok) {
// throw new Error("Failed to Send Data");
// } else {
// const json_data = await response.json();
// return json_data; // or handle the response as appropriate
// }
// }
+18 -51
View File
@@ -3,6 +3,10 @@ import * as THREE from "three";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
import { STORAGE_KEYS } from "~/contexts/Authentication/types";
import { environmentVariables } from "~/store";
import {
updateExistingScene,
uploadNewScene
} from "./api_fetchers";
/**
* Storyteller Studio API Manager
@@ -80,7 +84,6 @@ export class APIManager {
constructor() {
this.baseUrl = environmentVariables.value.BASE_API;
this.sessionToken = localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN) || "";
//this.baseUrl = "http://localhost:12345"
}
/**
@@ -89,22 +92,23 @@ export class APIManager {
* @param scene_media_file_token If null we will we will create a new save or copy the scene, if provided we will overwrite the scene.
* @returns APIManagerResponseMessage
*/
public async saveSceneState(
save_json: string,
scene_name: string,
scene_glb_media_file_token: string | null = null,
): Promise<string> {
const file = new File([save_json], `${scene_name}.glb`, {
public async saveSceneState({
saveJson, sceneTitle, sceneToken
}:{
saveJson: string,
sceneTitle: string,
sceneToken?: string,
}): Promise<string> {
const file = new File([saveJson], `${sceneTitle}.glb`, {
type: "application/json",
});
// will overwrite the scene on db if token exists
const upload_glb_response = await this.uploadEngineAsset(
file,
scene_glb_media_file_token,
);
const uploadSceneResponse = sceneToken
? await updateExistingScene(file, sceneToken, this.sessionToken)
: await uploadNewScene(file, sceneTitle, this.sessionToken);
return upload_glb_response["media_file_token"];
console.log(uploadSceneResponse)
return uploadSceneResponse["media_file_token"];
}
public async loadSceneState(
@@ -182,43 +186,6 @@ export class APIManager {
return file;
}
private async uploadEngineAsset(
file: File,
media_file_token: string | null,
): Promise<any> {
const url = `${this.baseUrl}/v1/media_files/write/engine_asset`;
const uuid = uuidv4();
const form_data = new FormData();
form_data.append("uuid_idempotency_token", uuid);
// update existing scene otherwise create new glb scene and use it's media_file_id
if (media_file_token != null) {
form_data.append("media_file_token", media_file_token);
}
form_data.append("file", file);
form_data.append("source", "file");
form_data.append("media_file_subtype", "scene_import");
form_data.append("media_file_class", "scene");
const response = await fetch(url, {
method: "POST",
// credentials: "include",
headers: {
Accept: "application/json",
session: this.sessionToken,
},
body: form_data,
});
if (!response.ok) {
throw new Error("Failed to Send Data");
} else {
const json_data = await response.json();
return json_data; // or handle the response as appropriate
}
}
public async uploadMedia(
blob: any,
fileName: string,
@@ -294,7 +261,7 @@ export class APIManager {
const result = await fetch(url)
.then((response) => response.json())
.then((data) => {
const result = data["media_files"].map((element) => {
const result = data["media_files"].map((element:any) => {
return new MediaFile(
element["public_bucket_path"],
element["media_type"],
+12 -6
View File
@@ -613,7 +613,13 @@ class Editor {
}
}
public async saveScene(name: string): Promise<string> {
public async saveScene({
sceneTitle,
sceneToken,
}:{
sceneTitle: string,
sceneToken?: string
}): Promise<string> {
// remove controls when saving scene.
this.removeTransformControls();
this.dispatchAppUiState({
@@ -644,11 +650,11 @@ class Editor {
};
// TODO turn scene information into and object ...
const result = await this.api_manager.saveSceneState(
JSON.stringify(save_data),
name,
this.current_scene_glb_media_token,
);
const result = await this.api_manager.saveSceneState({
saveJson: JSON.stringify(save_data),
sceneTitle,
sceneToken,
});
this.dispatchAppUiState({
type: APPUI_ACTION_TYPES.HIDE_EDITOR_LOADER,