mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-18 00:23:14 +00:00
Scene save operations (in-place overwrite vs copy) (#126)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user