mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Scene save operations (in-place overwrite vs copy) (#126)
This commit is contained in:
@@ -35,4 +35,9 @@ describe("deref", () => {
|
||||
const v3 = deref((() => {})());
|
||||
expect(v3).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return `undefined` when given an empty Ref", () => {
|
||||
const derefed = deref(createRef());
|
||||
expect(derefed).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ function isRef<T>(refOrValue: Ref<T> | T): refOrValue is Ref<T> {
|
||||
if (!refOrValue || typeof refOrValue !== "object")
|
||||
return false;
|
||||
|
||||
if (refOrValue instanceof Element)
|
||||
return false;
|
||||
|
||||
const keys = Object.keys(refOrValue);
|
||||
return keys.length === 1 && keys[0] === "value"
|
||||
return keys.length === 0 || (keys.length === 1 && keys[0] === "value")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { drag, on, provide } from "@storyteller/framework";
|
||||
import { on, provide } from "@storyteller/framework";
|
||||
import {
|
||||
type SceneSavedEvent,
|
||||
type SceneStateEvent,
|
||||
SceneState,
|
||||
StudioMode,
|
||||
@@ -78,25 +77,6 @@ export class AppElement extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
@on("scene-saved")
|
||||
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
|
||||
try {
|
||||
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
|
||||
|
||||
const token = await this.api.createScene(file);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("scene-uploaded", {
|
||||
detail: token,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
@on("scene-state")
|
||||
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
||||
console.log("scene state change:", SceneState[state]);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { bind, inject, observe } from "@storyteller/framework";
|
||||
import { DialogElement } from "@storyteller/studio-ui";
|
||||
import { REMOTE_SCENE_MANAGER, type RemoteSceneManager } from "@storyteller/studio-web";
|
||||
import { LitElement, TemplateResult, html, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import styles from "./file-menu.element.scss?inline";
|
||||
|
||||
@customElement("sts-file-menu")
|
||||
export class FileMenuElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property({ attribute: false })
|
||||
trigger = createRef<HTMLElement>();
|
||||
|
||||
@observe(["canSaveInPlace", "canSaveAsCopy"])
|
||||
@inject(REMOTE_SCENE_MANAGER)
|
||||
_sceneManager!: RemoteSceneManager;
|
||||
|
||||
@state() _saveAsCopyTitle = "";
|
||||
|
||||
_saveAsCopyFormRef = createRef<HTMLFormElement>();
|
||||
_saveAsCopyDialogRef = createRef<DialogElement>();
|
||||
|
||||
_onSaveInPlace(): void {
|
||||
this._sceneManager.saveInPlace().catch(console.error);
|
||||
}
|
||||
|
||||
_onSaveAsCopy(): void {
|
||||
const title = this._saveAsCopyTitle;
|
||||
this._saveAsCopyDialogRef.value?.close();
|
||||
this._saveAsCopyTitle = "";
|
||||
|
||||
this._sceneManager.saveAsCopy(title).catch(console.error);
|
||||
}
|
||||
|
||||
get _saveAsCopyDialogTemplate(): TemplateResult {
|
||||
return html`
|
||||
<sts-dialog-header icon="floppy-disk">
|
||||
Save Scene
|
||||
</sts-dialog-header>
|
||||
|
||||
<form
|
||||
${ref(this._saveAsCopyFormRef)}
|
||||
action=""
|
||||
@submit=${(event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this._onSaveAsCopy();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Title
|
||||
<sts-text-field
|
||||
autofocus
|
||||
name="title"
|
||||
required
|
||||
.value=${bind(this, "_saveAsCopyTitle")}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && this._saveAsCopyTitle)
|
||||
this._saveAsCopyFormRef.value?.requestSubmit();
|
||||
}}
|
||||
></sts-text-field>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<sts-dialog-footer>
|
||||
<sts-button
|
||||
@click=${() => {
|
||||
this._saveAsCopyDialogRef.value?.close();
|
||||
this._saveAsCopyTitle = "";
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</sts-button>
|
||||
<sts-button
|
||||
secondary
|
||||
?disabled=${!this._saveAsCopyTitle}
|
||||
@click=${() => {
|
||||
this._saveAsCopyFormRef.value?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</sts-button>
|
||||
</sts-dialog-footer>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<sts-menu
|
||||
autoClose
|
||||
.trigger=${this.trigger}
|
||||
>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+N"
|
||||
disabled
|
||||
@click=${() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
New scene
|
||||
</sts-button>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+Shift+N"
|
||||
disabled
|
||||
@click=${() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
New scene from template...
|
||||
</sts-button>
|
||||
<hr />
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+S"
|
||||
?disabled=${!this._sceneManager.canSaveInPlace}
|
||||
@click=${this._onSaveInPlace}
|
||||
>
|
||||
Save scene
|
||||
</sts-button>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+Shift+S"
|
||||
?disabled=${!this._sceneManager.canSaveAsCopy}
|
||||
@click=${() => {
|
||||
this._saveAsCopyDialogRef.value?.open();
|
||||
}}
|
||||
>
|
||||
Save scene as copy...
|
||||
</sts-button>
|
||||
</sts-menu>
|
||||
|
||||
<sts-dialog
|
||||
${ref(this._saveAsCopyDialogRef)}
|
||||
.template=${[this, this._saveAsCopyDialogTemplate] as const}
|
||||
></sts-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import "@storyteller/studio-ui/forms/radio";
|
||||
import "@storyteller/studio-ui/forms/select";
|
||||
import "@storyteller/studio-ui/toolbar";
|
||||
import "./anim-timeline-controls.element";
|
||||
import "./file-menu.element";
|
||||
|
||||
import "./studio-viewport.element.scss";
|
||||
import { match } from "@storyteller/utility";
|
||||
@@ -251,51 +252,7 @@ export class StudioViewportElement extends LitElement {
|
||||
})}
|
||||
</sts-studio>
|
||||
|
||||
<sts-menu
|
||||
autoClose
|
||||
.trigger=${this.#fileTriggerRef}
|
||||
>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+N"
|
||||
@click=${() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
New scene
|
||||
</sts-button>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+Shift+N"
|
||||
@click=${() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
New scene from template...
|
||||
</sts-button>
|
||||
<hr />
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+S"
|
||||
@click=${() => {
|
||||
// TODO: Should we show a spinner here while we're waiting?
|
||||
// Toast message for success/failure?
|
||||
// TODO: Need to disable this when the scene is not ours
|
||||
studio.saveScene();
|
||||
}}
|
||||
>
|
||||
Save scene
|
||||
</sts-button>
|
||||
<sts-button
|
||||
role="menuitem"
|
||||
keybind="Ctrl+Shift+S"
|
||||
@click=${() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
Save scene as copy...
|
||||
</sts-button>
|
||||
</sts-menu>
|
||||
<sts-file-menu .trigger=${this.#fileTriggerRef}></sts-file-menu>
|
||||
|
||||
<sts-dialog
|
||||
.trigger=${this.#mediaLibraryTriggerRef}
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
|
||||
:host(.disabled),
|
||||
:host(.disabled:hover) {
|
||||
background: #0003;
|
||||
color: #FFF6;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -58,7 +57,6 @@
|
||||
}
|
||||
|
||||
.keybind {
|
||||
// margin-inline-start: auto;
|
||||
flex: 1 0 0;
|
||||
margin-inline-start: 2.25rem;
|
||||
color: rgba(#FFF, 0.75);
|
||||
@@ -67,4 +65,8 @@
|
||||
weight: 400;
|
||||
}
|
||||
text-align: end;
|
||||
|
||||
:host(.disabled) & {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,24 @@ export class DialogElement extends LitElement {
|
||||
|
||||
#overlayHandle?: WeakRef<OverlayHandle>;
|
||||
|
||||
get #overlayTemplate(): [LitElement, TemplateResult] | undefined {
|
||||
if (!this.template)
|
||||
return;
|
||||
|
||||
if (this.skipWrapper)
|
||||
return this.template;
|
||||
|
||||
const [host, template] = this.template;
|
||||
return [host, html`
|
||||
<sts-dialog-base
|
||||
role=${ifDefined(this.dialogRole)}
|
||||
@close=${() => this.close()}
|
||||
>
|
||||
${template}
|
||||
</sts-dialog-base>
|
||||
`]
|
||||
}
|
||||
|
||||
open(): void {
|
||||
if (!this.tag && !this.template)
|
||||
return;
|
||||
@@ -348,23 +366,12 @@ export class DialogElement extends LitElement {
|
||||
const onBackdropClick = this.onBackdropClick;
|
||||
|
||||
if (this.template) {
|
||||
const [host, template] = this.template;
|
||||
const [host, template] = this.#overlayTemplate!;
|
||||
|
||||
this.#overlayHandle = this._overlay.attach(
|
||||
host,
|
||||
this.skipWrapper ? template : html`
|
||||
<sts-dialog-base
|
||||
role=${ifDefined(this.dialogRole)}
|
||||
@close=${() => this.close()}
|
||||
>
|
||||
${template}
|
||||
</sts-dialog-base>
|
||||
`,
|
||||
{
|
||||
containerStyles,
|
||||
onBackdropClick,
|
||||
},
|
||||
);
|
||||
this.#overlayHandle = this._overlay.attach(host, template, {
|
||||
containerStyles,
|
||||
onBackdropClick,
|
||||
});
|
||||
} else {
|
||||
this.#overlayHandle = this._overlay.attach(this, html`
|
||||
<${unsafeStatic(this.tag!)}
|
||||
@@ -400,7 +407,7 @@ export class DialogElement extends LitElement {
|
||||
|
||||
if (changes.has("template")) {
|
||||
if (this.#overlayHandle && this.template)
|
||||
this._overlay.reRender(this.#overlayHandle, this.template);
|
||||
this._overlay.reRender(this.#overlayHandle, this.#overlayTemplate!);
|
||||
}
|
||||
|
||||
super.updated(changes);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
border-radius: unset;
|
||||
}
|
||||
|
||||
::slotted(sts-button:hover) {
|
||||
::slotted(sts-button:not(.disabled):hover) {
|
||||
background: var(--neutral-300);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "ESNext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "ESNext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
|
||||
Reference in New Issue
Block a user