Scene save operations (in-place overwrite vs copy) (#126)

This commit is contained in:
Danny McGee
2024-04-09 15:39:32 -04:00
committed by GitHub
parent 5cd98b4a35
commit 03f90eee47
16 changed files with 418 additions and 105 deletions
+1
View File
@@ -7,6 +7,7 @@ export * from "./lib/inspector/entity-inspector.element";
export * from "./lib/inspector/inspector.types";
export * from "./lib/loading.element";
export * from "./lib/media-library-dialog.element";
export * from "./lib/save-scene";
export * from "./lib/scene-data-provider.element";
export * from "./lib/scene-hierarchy.element";
export * from "./lib/studio.element";
@@ -1,3 +1,7 @@
:host {
display: contents;
}
::slotted([slot="canvas"]) {
display: none;
}
+16
View File
@@ -0,0 +1,16 @@
import * as studio from "@storyteller/studio";
/**
* A simple wrapper around {@linkcode studio.saveScene} that returns a Promise.
*/
export function saveScene() {
return new Promise<string>(resolve => {
window.addEventListener("scene-saved", ({ detail: scene }) => {
resolve(scene);
}, {
once: true,
});
studio.saveScene();
});
}
@@ -1,4 +1,4 @@
import { DynamicProvider, UniqueToken, on, provide } from "@storyteller/framework";
import { DynamicProvider, UniqueToken, inject, on, provide } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
SceneElement,
@@ -10,11 +10,15 @@ import {
} from "@storyteller/studio";
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
import { match } from "@storyteller/utility";
import { LitElement, type PropertyValues, html } from "lit";
import { LitElement, type PropertyValues, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { InspectorComponent } from "./inspector/inspector.types";
import { type MediaFile, type MediaFileToken, StorytellerApi } from "./storyteller-api";
import { STUDIO_PARAMS, type StudioParams } from "./url-params";
import { saveScene } from "./save-scene";
export const SCENE_DATA_PROVIDER = UniqueToken.create<SceneDataProvider>();
export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly tree: readonly SceneObject[];
readonly entityMap: ReadonlyMap<string, SceneObject>;
@@ -23,7 +27,50 @@ export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly selectionComponents: readonly InspectorComponent[];
}
export const SCENE_DATA_PROVIDER = UniqueToken.create<SceneDataProvider>();
export const REMOTE_SCENE_MANAGER = UniqueToken.create<RemoteSceneManager>();
export interface RemoteSceneManager extends DynamicProvider<RemoteSceneManager> {
/**
* The media token for the current scene. Can be `null` if:
*
* - This is a new scene that hasn't been saved yet
* - We're in a `StudioMode.Viewer` instance for some other asset type, like
* an FBX/glTF import or an animation
* - We're in a dev instance loading assets from the local `assets` directory
* - Probably some other scenarios I'm not considering
*/
readonly sceneToken: MediaFileToken | null;
/**
* Indicates whether the user is permitted to save the current scene by
* overwriting the remote media file.
*/
readonly canSaveInPlace: boolean;
/**
* Indicates whether the user is permitted to save the current scene as a
* copy to their account. (Presumably this should only be `false` if the user
* is not logged in.)
*/
readonly canSaveAsCopy: boolean;
/**
* Save the current scene to the user's account by overwriting the existing
* media file.
*
* @returns a rejected Promise if there is no active {@linkcode sceneToken}
* or if {@linkcode canSaveInPlace} is false.
*/
saveInPlace(): Promise<void>;
/**
* Save the current scene to a new {@linkcode MediaFile} in the user's
* account.
*
* @returns the new scene token.
* @returns a rejected Promise if {@linkcode canSaveAsCopy} is false.
*/
saveAsCopy(title: string): Promise<MediaFileToken>;
}
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
@@ -32,11 +79,29 @@ export interface SceneObject extends Omit<studio.SceneObject, "children"> {
@customElement("sts-scene-data-provider")
@provide(SCENE_DATA_PROVIDER)
@provide(REMOTE_SCENE_MANAGER)
@provide(TREE_SELECTION_PROVIDER)
export class SceneDataProviderElement
extends LitElement
implements SceneDataProvider, TreeSelectionProvider
implements
SceneDataProvider,
RemoteSceneManager,
TreeSelectionProvider
{
static override styles = css`
:host {
display: contents;
}
`;
@inject(STUDIO_PARAMS)
_params!: StudioParams;
@inject(StorytellerApi)
_api!: StorytellerApi;
@state() sceneToken: MediaFileToken | null = null;
get tree() { return this._tree; }
get entityMap() { return this.#entityMap; }
@@ -50,6 +115,9 @@ export class SceneDataProviderElement
get selectionComponents() { return this._components; }
get canSaveInPlace() { return this._canSaveInPlace; }
get canSaveAsCopy() { return this._canSaveAsCopy; }
@state() _tree: SceneObject[] = [];
#sceneObjectsFlat: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
@@ -57,13 +125,109 @@ export class SceneDataProviderElement
@state() _selection: string | null = null;
@state() _components: InspectorComponent[] = [];
@state() _canSaveInPlace = false;
@state() _canSaveAsCopy = false;
async saveInPlace(): Promise<void> {
if (!this.canSaveInPlace)
return Promise.reject(new Error("User not permitted to overwrite the current scene"));
try {
const scene = await saveScene();
const file = new File([scene], "scene.scn.ron", { type: "text/plain" });
if (!this.sceneToken)
return Promise.reject(new Error("Unable to save scene in-place: no active scene token"));
await this._api.writeSceneFile(this.sceneToken, file);
}
catch (err) {
return Promise.reject(err);
}
}
async saveAsCopy(title: string): Promise<MediaFileToken> {
if (!this.canSaveAsCopy)
return Promise.reject(new Error("User not permitted to save the scene as a copy"))
try {
const scene = await saveScene();
const file = new File([scene], "scene.scn.ron", { type: "text/plain" });
this.sceneToken = await this._api.createScene(file, title);
this.dispatchEvent(new CustomEvent("scene-uploaded", {
detail: this.sceneToken,
bubbles: true,
composed: true,
}));
return this.sceneToken;
}
catch (err) {
return Promise.reject(err);
}
}
override connectedCallback(): void {
if (this._params.scene != null) {
this.sceneToken = this._params.scene
.match(/^remote:\/\/([a-zA-Z0-9_]+)\.scn\.ron/)
?.[1]
?? null;
}
super.connectedCallback();
}
protected override async willUpdate(changes: PropertyValues<this>): Promise<void> {
if (changes.has("sceneToken")) {
if (!this.sceneToken)
this._canSaveInPlace = false;
try {
const session = await this._api.session();
if (session.logged_in)
this._canSaveAsCopy = true;
if (this.sceneToken != null) {
const sceneFile = await this._api.mediaFile(this.sceneToken);
if (
session.user?.user_token != null
&& sceneFile.maybe_creator_user?.user_token === session.user.user_token
) {
this._canSaveInPlace = true;
}
}
}
catch (err) {
console.error(err);
}
}
super.willUpdate(changes);
}
// TODO: This workflow allows dependents of this provider to listen for the
// "property-changes" event and manually trigger updates when relevant
// properties change, but it's pretty fiddly to set up. Probably not
// worth fixing before we migrate over to Bevy-native UI.
protected override update(changes: PropertyValues<this>): void {
const downstreamChanges: PropertyValues<SceneDataProvider & TreeSelectionProvider>
= new Map();
type Interfaces
= SceneDataProvider
& RemoteSceneManager
& TreeSelectionProvider
;
const downstreamChanges: PropertyValues<Interfaces> = new Map();
if (changes.has("sceneToken"))
downstreamChanges.set("sceneToken", changes.get("sceneToken"));
if (changes.has("_canSaveInPlace"))
downstreamChanges.set("canSaveInPlace", changes.get("_canSaveInPlace"));
if (changes.has("_canSaveAsCopy"))
downstreamChanges.set("canSaveAsCopy", changes.get("_canSaveAsCopy"));
if (changes.has("_tree")) {
downstreamChanges.set("tree", changes.get("_tree"));
+35 -8
View File
@@ -6,7 +6,7 @@ import * as studio from "@storyteller/studio";
export const mockApi = window.location.search.includes("mock-api");
interface IStorytellerApi {
createScene(file: File): Promise<MediaFileToken>
createScene(file: File, title?: string): Promise<MediaFileToken>
session(): Promise<Session>
listMediaFiles(options: ListMediaFilesOptions): Promise<ListMediaFilesResult>
uploadMediaFile(
@@ -16,6 +16,7 @@ interface IStorytellerApi {
renameMediaFile(token: MediaFileToken, name: string): Promise<void>
convertFbxToGltf(token: MediaFileToken): Promise<void>
mediaFile(token: MediaFileToken): Promise<MediaFile>
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken>;
}
export class StorytellerRemoteApi implements IStorytellerApi {
@@ -24,15 +25,16 @@ export class StorytellerRemoteApi implements IStorytellerApi {
withCredentials: true,
});
async createScene(file: File): Promise<MediaFileToken> {
async createScene(file: File, title = "Untitled"): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"v1/engine/create_scene",
{
uuid_idempotency_token: uuid(),
file,
title,
}, {
headers: { Accept: "text/plain" },
},
headers: { Accept: "text/plain" },
},
);
this.#validateResponse(response);
@@ -108,6 +110,23 @@ export class StorytellerRemoteApi implements IStorytellerApi {
return response.data.media_file;
}
async writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"v1/media_files/write/scene_file",
{
uuid_idempotency_token: uuid(),
media_file_token: token,
file,
},
{
headers: { Accept: "text/plain" },
},
);
this.#validateResponse(response);
return response.data.media_file_token;
}
#validateResponse<R extends AxiosResponse>(response: R): asserts response is OkResponse<R> {
if (response.status !== 200)
throw new Error(`API responded with status ${response.status}: "${response.statusText}"`);
@@ -177,7 +196,7 @@ export class StorytellerLocalApi implements IStorytellerApi {
return this.database
}
async createScene(file: File): Promise<string> {
async createScene(file: File, title = "Untitled"): Promise<string> {
let token = uuid();
let original_file_name = file.name;
let filename = token + "-" + file.name;
@@ -190,7 +209,7 @@ export class StorytellerLocalApi implements IStorytellerApi {
public_bucket_path: filename,
creator_set_visibility: Visibility.Public,
is_emulated_media_file: false,
maybe_title: filename,
maybe_title: title,
maybe_original_filename: original_file_name,
stats: {
positive_rating_count: 0,
@@ -425,6 +444,10 @@ export class StorytellerLocalApi implements IStorytellerApi {
return file;
}
async writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken> {
throw new Error("StorytellerLocalApi.writeSceneFile is not yet implemented");
}
async mediaBlob(path: string): Promise<Uint8Array> {
let db = await this.getDatabase();
let file = await db.get('file-blobs', path);
@@ -436,8 +459,8 @@ export class StorytellerLocalApi implements IStorytellerApi {
}
export class StorytellerApi implements IStorytellerApi {
createScene(file: File): Promise<string> {
return this.apiBase.createScene(file)
createScene(file: File, title?: string): Promise<string> {
return this.apiBase.createScene(file, title)
}
session(): Promise<Session> {
return this.apiBase.session()
@@ -457,6 +480,10 @@ export class StorytellerApi implements IStorytellerApi {
mediaFile(token: string): Promise<MediaFile> {
return this.apiBase.mediaFile(token)
}
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken> {
return this.apiBase.writeSceneFile(token, file);
}
apiBase: IStorytellerApi;
constructor(apiBase: IStorytellerApi) {