Media Library: Importing and spawning assets (#109)

This PR updates the Media Library feature to implement asset imports
from the local file system, and spawning assets from the library into
the scene. Additionally, some placeholder functionality has been
scaffolded for adding animations onto existing characters in the scene.

### Additional Changes
* Dialogs can be set to `nonBlocking` to allow interaction with
  background elements (note: this is fraught for accessibility and likely
  temporary, to facilitate testing while the UI/UX redesign is in
  progress)
* Dialogs can be moved around the screen by dragging their title-bars
* The `scene` module has been moved into a folder with a separate
  `wasm.rs` file for its submodule. (`scene/mod.rs` remains one of our
  largest single Rust files and could probably stand to be partitioned
  further.)

### To-Do
* Support for adding animations is not yet fully implemented, though
  some prerequisite functionality has been scaffolded. Namely, the "Add to
  Scene" button will be disabled if:
  - There is a mix of animations and non-animations selected in the Media
    Library
  - There is more than one animation selected in the Media Library
  - There is one animation selected in the Media Library, but no
    animatable character selected in the scene
* Adding full Storyteller-format scenes to the existing scene is not a
  use case that has been tested, and may not be a use case we wish to
  support in the long-term, as some of the data stored in Storyteller
  scenes are intended to be "singleton" resources that may conflict with
  existing data in the scene.
  
  **Attempting to spawn a Storyteller scene through the Media Library has
  not been explicitly blocked for now, but may cause unexpected
  behavior.** This needs additional UX consideration and grooming.
This commit is contained in:
Danny McGee
2024-03-27 19:20:33 -04:00
committed by GitHub
parent b905a0f635
commit 1163192d24
19 changed files with 792 additions and 140 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ import { getMethodDescriptor, prependRoutine } from "./internal";
* The event name can be prefixed with `window:` or `document:` to add the
* listener to one of those targets instead of the custom element itself.
*/
export function on(eventSelector: string) {
export function on(eventSelector: string, options?: AddEventListenerOptions) {
const [targetName, eventName] = eventSelector.includes(":")
? eventSelector.split(":")
: [null, eventSelector];
@@ -34,9 +34,9 @@ export function on(eventSelector: string) {
this[$handler] ??= handler.bind(this);
if (target) {
target.addEventListener(eventName, this[$handler]);
target.addEventListener(eventName, this[$handler], options);
} else {
this.addEventListener(eventName, this[$handler]);
this.addEventListener(eventName, this[$handler], options);
}
});
+5 -4
View File
@@ -75,9 +75,9 @@ export class AppElement extends LitElement {
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
if (mock_save_load()) {
/*
// NB(brandon, 2024-03-22): Hey Lee-Orr, I'm commenting this out since the file
// NB(brandon, 2024-03-22): Hey Lee-Orr, I'm commenting this out since the file
picker isn't defined and the Netlify builds are breaking. Feel free to uncomment
this for your testing. We probably just need a local definition file so the
this for your testing. We probably just need a local definition file so the
builds are happy.
let handle = await window.showSaveFilePicker({
@@ -169,9 +169,9 @@ export class AppElement extends LitElement {
protected override render() {
return html`
<sts-overlay-provider>
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<sts-overlay-provider>
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
<!-- Scene view -->
@@ -231,6 +231,7 @@ export class AppElement extends LitElement {
<sts-dialog
${ref(this.#mediaLibraryRef)}
tag="sts-media-library-dialog"
nonBlocking
></sts-dialog>
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
@@ -318,9 +319,9 @@ export class AppElement extends LitElement {
${this.loading ? html`
<sts-loading></sts-loading>
` : nothing}
</sts-overlay-provider>
</sts-scene-data-provider>
</sts-canvas-provider>
</sts-overlay-provider>
`;
}
}
@@ -10,8 +10,9 @@
background: #0006;
color: #FFFC;
backdrop-filter: blur(32px);
border-radius: 0.5rem;
border-radius: 1rem;
overflow: hidden;
pointer-events: auto;
}
.main {
@@ -22,13 +23,23 @@
.content {
display: flex;
flex-direction: column;
flex: 0 0 auto;
flex: 1 0 0;
gap: 1rem;
border: {
inline: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
bottom-right-radius: 1rem;
bottom-left-radius: 1rem;
}
padding: 1rem;
&.with-footer {
border: {
block-end: none;
bottom-right-radius: 0;
bottom-left-radius: 0;
}
}
}
[part="sidebar"]:not(:empty) {
@@ -36,6 +47,9 @@
background: #0004;
+ .content {
border-inline-start: none;
border: {
inline-start: none;
bottom-left-radius: 0;
}
}
}
@@ -1,6 +1,7 @@
import { on } from "@storyteller/framework";
import { LitElement, TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property, queryAssignedElements } from "lit/decorators.js";
import { LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property, queryAssignedElements, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { v4 as uuid } from "uuid";
import { type DialogHeaderElement } from "./dialog-header.element";
@@ -62,14 +63,37 @@ export class DialogBaseElement extends LitElement {
override ariaModal = "true";
override tabIndex = -1;
@state() _hasFooter = false;
@queryAssignedElements({ slot: "dialog-header" })
dialogHeaders!: Element[];
@queryAssignedElements({ slot: "footer" })
dialogFooters!: Element[];
@state() _translateX?: number;
@state() _translateY?: number;
override connectedCallback(): void {
this.focus();
super.connectedCallback();
}
protected override update(changes: PropertyValues<this>): void {
if (
changes.has("_translateX") || changes.has("_translateY")
&& (this._translateX != null || this._translateY != null)
) {
this.style.setProperty(
"transform",
`translateX(${this._translateX ?? 0}px) ` +
`translateY(${this._translateY ?? 0}px)`
);
}
super.update(changes);
}
@on("keydown")
onKeydown(event: KeyboardEvent): void {
if (/^(Escape|Shift)$/.test(event.key))
@@ -79,6 +103,12 @@ export class DialogBaseElement extends LitElement {
this.dispatchEvent(new CustomEvent("close"));
}
@on("translate", { capture: true })
onTranslate({ detail: { x, y }}: CustomEvent<{ x: number; y: number }>): void {
this._translateX = x;
this._translateY = y;
}
onDialogHeaderChanged(): void {
if (this.dialogHeaders.length && !this.ariaLabel) {
const header = this.dialogHeaders[0];
@@ -90,6 +120,10 @@ export class DialogBaseElement extends LitElement {
}
}
onFooterChanged(): void {
this._hasFooter = this.dialogFooters.length > 0;
}
protected override render(): TemplateResult {
return html`
<slot
@@ -107,12 +141,21 @@ export class DialogBaseElement extends LitElement {
<section class="main" part="main">
<slot name="sidebar" part="sidebar"></slot>
<section class="content" part="content">
<section
class=${classMap({
"content": true,
"with-footer": this._hasFooter,
})}
part="content"
>
<slot></slot>
</section>
</section>
<slot name="footer"></slot>
<slot
name="footer"
@slotchange=${this.onFooterChanged}
></slot>
`;
}
}
@@ -0,0 +1,13 @@
:host {
box-sizing: border-box;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0 1rem 1rem;
border: {
inline: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
bottom-right-radius: 1rem;
bottom-left-radius: 1rem;
}
}
@@ -0,0 +1,16 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import styles from "./dialog-footer.element.scss?inline";
@customElement("sts-dialog-footer")
export class DialogFooterElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ reflect: true })
override slot = "footer";
protected override render = () => html`
<slot></slot>
`;
}
@@ -3,18 +3,18 @@
:host {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem;
padding: 0 {
inline-start: 1rem;
}
background: #0006;
}
.title {
flex: 1 0 0;
display: flex;
height: 3rem;
align-items: center;
gap: math.div(12, 16) * 1rem;
padding-inline-start: 1rem;
user-select: none;
&__icon {
color: #FFFC;
@@ -1,3 +1,4 @@
import { drag } from "@storyteller/framework";
import { LitElement, html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { v4 as uuid } from "uuid";
@@ -53,6 +54,9 @@ export class DialogHeaderElement extends LitElement {
@property() icon?: string;
#translateX?: number;
#translateY?: number;
onClose(): void {
this.dispatchEvent(new CustomEvent("close", {
bubbles: true,
@@ -60,10 +64,30 @@ export class DialogHeaderElement extends LitElement {
}));
}
onDrag(event: PointerEvent): void {
this.#translateX ??= 0;
this.#translateY ??= 0;
this.#translateX += event.movementX;
this.#translateY += event.movementY;
this.dispatchEvent(new CustomEvent("translate", {
detail: {
x: this.#translateX,
y: this.#translateY,
},
bubbles: true,
composed: true,
}));
}
protected override render = () => html`
<div
class="title"
part="title"
${drag({
move: this.onDrag,
})}
>
${this.icon ? html`
<sts-icon
+18 -3
View File
@@ -4,6 +4,7 @@ import { LitElement, TemplateResult, type PropertyValues } from "lit";
import { html, unsafeStatic } from "lit/static-html.js";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { StyleInfo } from "lit/directives/style-map.js";
import { Ref } from "lit/directives/ref.js";
import {
@@ -293,6 +294,14 @@ export class DialogElement extends LitElement {
@property({ type: Boolean })
ignoreBackdropClick = false;
/**
* If this property is `true`, the dialog backdrop will be completely
* transparent and allow the user to interact with background elements while
* the dialog is still present.
*/
@property({ type: Boolean })
nonBlocking = false;
/**
* ARIA role to pass to the custom element defined by `tag`.
*
@@ -318,12 +327,18 @@ export class DialogElement extends LitElement {
if (!this.tag && !this.template)
return;
const containerStyles = {
const containerStyles: StyleInfo = {
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#1A1A2766",
};
if (this.nonBlocking) {
containerStyles["pointer-events"] = "none";
} else {
containerStyles["background"] = "#1A1A2766";
}
const onBackdropClick = this.onBackdropClick;
if (this.template) {
@@ -393,7 +408,7 @@ export class DialogElement extends LitElement {
}
onBackdropClick = (): void => {
if (!this.ignoreBackdropClick)
if (!this.ignoreBackdropClick && !this.nonBlocking)
this.close();
}
}
+2
View File
@@ -1,7 +1,9 @@
import "./dialog-base.element";
import "./dialog-footer.element";
import "./dialog-header.element";
import "./dialog.element";
export * from "./dialog.element";
export * from "./dialog-base.element";
export * from "./dialog-footer.element";
export * from "./dialog-header.element";
@@ -1,5 +1,5 @@
import { on } from "@storyteller/framework";
import { html, nothing, unsafeCSS } from "lit";
import { PropertyValues, html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -27,6 +27,13 @@ export class TextFieldElement extends FormFieldBaseElement {
@property({ attribute: "icon-pre" }) iconPre?: string;
@property({ attribute: "icon-post" }) iconPost?: string;
protected override update(changes: PropertyValues<this>): void {
if (changes.has("value"))
this.internals.setFormValue(this.value);
super.update(changes);
}
@on("keydown")
@on("keyup")
blockKeypressBubbling(event: KeyboardEvent): void {
+3 -1
View File
@@ -44,6 +44,7 @@ import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/ar
import farMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw";
import fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.svg?raw";
import fasRecord from "@fortawesome/fontawesome-pro/svgs/solid/circle.svg?raw";
import fasUpload from "@fortawesome/fontawesome-pro/svgs/solid/upload.svg?raw";
import customCylinder from "../assets/icons/cylinder.svg?raw";
import customSphere from "../assets/icons/sphere.svg?raw";
@@ -105,6 +106,7 @@ function renderSvg(iconId: string) {
"pause": () => fasPause,
"plus-large": () => fasPlusLarge,
"question": () => fasQuestion,
"record": () => fasRecord,
"rotate": () => farRotate,
"scale": () => farScale,
"scene": () => farMountainSun,
@@ -113,10 +115,10 @@ function renderSvg(iconId: string) {
"sphere": () => customSphere,
"torus": () => customTorus,
"up-down-left-right": () => farUpDownLeftRight,
"upload": () => fasUpload,
"video": () => farVideo,
"volume": () => farVolume,
"xmark": () => fasXmark,
"record": () => fasRecord,
_: () => null,
});
@@ -0,0 +1,60 @@
@use "sass:math";
*, *::before, *::after {
box-sizing: border-box;
}
:host {
display: contents;
}
.dialog {
min-width: 480px;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.label {
color: #FFF9;
font-size: math.div(15, 16) * 1rem;
}
// TODO: Make a component for this
select.input {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2;
color: #FFFC;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
&:focus {
outline: none;
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
}
option {
color-scheme: dark !important;
background-color: #000000;
color: #FFFC;
&:disabled {
color: #FFF6;
}
}
@@ -0,0 +1,231 @@
import { bind } from "@storyteller/framework";
import { LitElement, PropertyValues, TemplateResult, html, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import { MediaFileClass, MediaFileSubtype } from "./storyteller-api";
import "@storyteller/studio-ui/dialog";
import styles from "./asset-import-dialog.element.scss?inline";
export interface AssetImport {
type: MediaFileClass;
title: string;
animationSkeleton?: AnimationSkeleton;
}
export type AnimationSkeleton
= MediaFileSubtype.Mixamo
| MediaFileSubtype.MocapNet
| MediaFileSubtype.AnimationOnly
;
@customElement("sts-asset-import-dialog")
export class AssetImportDialogElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
file?: File;
@property()
assetType?: MediaFileClass;
@state() _title = "";
@state() _animationSkeleton?: AnimationSkeleton;
@state() _formValid = false;
#formRef = createRef<HTMLFormElement>();
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("file") && !this._title) {
this._title = this.file?.name.match(/^(.+?)\./)?.[1] ?? "";
}
super.willUpdate(changes);
}
protected override updated(changes: PropertyValues<this>): void {
this._formValid = this.#formRef.value?.checkValidity() ?? false;
super.updated(changes);
}
onFormSubmit(event: SubmitEvent): void {
console.log("AssetImportDialog.onFormSubmit", { ...event });
event.preventDefault();
event.stopPropagation();
const data = new FormData(event.target as HTMLFormElement);
const dataObject = Object.fromEntries(data.entries()) as AssetImport;
this.dispatchEvent(new CustomEvent<AssetImport>("import-asset", { detail: dataObject }));
}
onAssetTypeChange(event: Event): void {
const input = event.target as HTMLSelectElement;
if (!input.value) {
this.assetType = undefined;
} else {
this.assetType = input.value as MediaFileClass;
}
}
onAnimationSkeletonChange(event: Event): void {
const input = event.target as HTMLSelectElement;
if (!input.value) {
this._animationSkeleton = undefined;
} else {
this._animationSkeleton = input.value as AnimationSkeleton;
}
}
protected override render = (): TemplateResult => html`
<sts-dialog-base class="dialog">
<sts-dialog-header icon="import">
Import Asset
</sts-dialog-header>
<form
${ref(this.#formRef)}
class="form"
action=""
@submit=${this.onFormSubmit}
>
<div class="form-field">
<label class="label" for="import-asset-title">
Name
</label>
<sts-text-field
class="input"
id="import-asset-title"
autofocus
name="title"
.value=${bind(this, "_title")}
></sts-text-field>
</div>
<div class="form-field">
<label class="label" for="import-asset-type">
Asset Type
</label>
<select
class="input"
id="import-asset-type"
name="type"
required
@change=${this.onAssetTypeChange}
>
<option
value=""
?selected=${this.assetType == null}
></option>
${ASSET_TYPES.map(({ type, label, icon, disabled, tooltip }) => html`
<option
value=${type}
?selected=${this.assetType === type}
?disabled=${disabled}
title=${ifDefined(tooltip)}
>
<sts-icon .icon=${icon}></sts-icon>
${label}
</option>
`)}
</select>
</div>
${this.assetType === MediaFileClass.Animation ? html`
<label class="label" for="import-animation-skeleton">
Skeleton Type
</label>
<select
class="input"
id="import-animation-skeleton"
name="animationSkeleton"
required
@change=${this.onAnimationSkeletonChange}
>
<option value="" selected></option>
<option
value=${MediaFileSubtype.Mixamo}
title="For animations downloaded from Adobe Mixamo"
>
Mixamo
</option>
<option
value=${MediaFileSubtype.MocapNet}
title="For motion capture data produced by MocapNET"
>
MocapNET
</option>
<option
value=${MediaFileSubtype.AnimationOnly}
title=${"For animations that are designed to play on a Rigify "
+ "skeleton (deformation bones only)"}
>
Rigify (Advanced)
</option>
</select>
` : nothing}
</form>
<sts-dialog-footer>
<sts-button
icon="upload"
?disabled=${!this._formValid}
@click=${() => {
this.#formRef.value?.requestSubmit();
}}
>
Upload
</sts-button>
</sts-dialog-footer>
</sts-dialog-base>
`;
}
interface AssetType {
type: MediaFileClass;
label: string;
icon: string;
disabled?: boolean;
tooltip?: string;
}
const ASSET_TYPES: readonly AssetType[] = [
{
type: MediaFileClass.Animation,
label: "Animation",
icon: "film",
},
{
type: MediaFileClass.Audio,
label: "Audio",
icon: "volume",
disabled: true,
tooltip: "Coming soon!",
},
{
type: MediaFileClass.Character,
label: "Character",
icon: "figure",
},
{
type: MediaFileClass.Prop,
label: "Prop",
icon: "cube",
},
{
type: MediaFileClass.Scene,
label: "Scene",
icon: "scene",
},
];
declare global {
interface FormData {
// I have no idea why this method is not included in the TS DOM library,
// but it isn't.
entries(): ReturnType<typeof Object.entries>
}
}
@@ -1,6 +1,7 @@
import * as studio from "@storyteller/studio";
import { SceneElement } from "@storyteller/studio";
import { bind, inject } from "@storyteller/framework";
import { ButtonElement } from "@storyteller/studio-ui/button";
import { DialogBaseElement } from "@storyteller/studio-ui/dialog";
import { DialogBaseElement, DialogElement } from "@storyteller/studio-ui/dialog";
import {
type MediaFile as IMediaFile,
MediaFileClass,
@@ -9,8 +10,11 @@ import {
MediaFileType,
StorytellerApi,
Visibility,
UploadMediaFileOptions,
SCENE_DATA_PROVIDER,
type SceneDataProvider,
} from "@storyteller/studio-web";
import { match, type Pred } from "@storyteller/utility";
import { exists, match, type Pred } from "@storyteller/utility";
import {
html,
nothing,
@@ -19,13 +23,17 @@ import {
unsafeCSS,
css,
} from "lit";
import { customElement, queryAll, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import { AssetImport } from "./asset-import-dialog.element";
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/icon";
import "@storyteller/studio-ui/forms/text-field";
import "./asset-import-dialog.element";
import styles from "./media-library-dialog.element.scss?inline";
@@ -38,7 +46,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
override heading = "Media Library";
@state() _mediaFiles?: MediaFile[];
@state() _mediaFiles?: Map<MediaFileToken, MediaFile>;
@state() _searchTerm = "";
@state() _selected: MediaFileToken[] = [];
@state() _activeCategory: MediaFileCategory = MEDIA_FILE_CATEGORIES[0];
@@ -48,13 +56,20 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
@inject(StorytellerApi)
_api!: StorytellerApi;
@queryAll("[data-import-trigger]")
importTriggers!: ButtonElement[];
@inject(SCENE_DATA_PROVIDER)
_sceneData!: SceneDataProvider;
#dateFmt = new Intl.DateTimeFormat("en-us");
#importDialogRef = createRef<DialogElement>();
#transientFileInput?: HTMLInputElement;
@state() _uploadedFile?: File;
get #mediaFiles() {
return this._mediaFiles?.filter(it =>
if (!this._mediaFiles)
return null;
return Array.from(this._mediaFiles.values()).filter(it =>
this._filters.every(pred => pred(it))
);
}
@@ -70,11 +85,41 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
return ResultsStatus.NonEmpty;
}
get #canAddToScene() {
const noAnimations = this._selected.every(token => {
const mediaFile = this._mediaFiles?.get(token);
if (!mediaFile) return false;
return mediaFile.mediaClass !== MediaFileClass.Animation;
});
return noAnimations || this.#canAddAnimation;
}
get #canAddAnimation() {
const oneAnimationSelected = this._selected.length === 1
&& this._mediaFiles?.get(this._selected[0])?.mediaClass === MediaFileClass.Animation;
const skeletonSelected = this._sceneData.selectedObject?.type === SceneElement.Skeleton;
return oneAnimationSelected && skeletonSelected;
}
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
this.loadResults();
super.connectedCallback();
}
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("selectedObject"))
this.requestUpdate();
}
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("_activeCategory") || changes.has("_searchTerm")) {
this._filters = [
@@ -108,8 +153,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
const results = await Promise.all(
response.results.map(({ token }) => this._api.mediaFile(token))
);
console.log("Results:", results);
this._mediaFiles = results.map(info => new MediaFile(info));
this._mediaFiles = new Map(
results.map(info => [info.token, new MediaFile(info)])
);
}
catch (err) {
if (typeof err === "object" && err !== null && err instanceof Error) {
@@ -146,14 +194,116 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
this._selected = [];
}
get importDialogTemplate() {
return html`
<sts-dialog-header icon="import">
Import Asset
</sts-dialog-header>
promptForFileUpload(): void {
this.#transientFileInput = document.createElement("input");
this.#transientFileInput.type = "file";
this.#transientFileInput.accept = match (this._activeCategory.mediaClass, {
[MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf",
[MediaFileClass.Audio]: () => ".wav,.mp3",
[MediaFileClass.Character]: () => ".fbx,.gltf",
[MediaFileClass.Prop]: () => ".fbx,.gltf",
[MediaFileClass.Scene]: () => ".fbx,.gltf,.scn.ron",
_: () => "",
});
this.#transientFileInput.multiple = false;
<p>Lorem ipsum dolor sit amet</p>
`;
this.#transientFileInput.addEventListener("input", this.onLocalFileLoaded.bind(this), {
once: true,
});
this.#transientFileInput.click();
}
onLocalFileLoaded(): void {
console.log("files:", this.#transientFileInput?.files);
const file = this.#transientFileInput?.files?.[0];
if (file != null) {
this._uploadedFile = file;
setTimeout(() => {
this.#importDialogRef.value?.open();
});
}
}
async onImportAsset({ detail: data}: CustomEvent<AssetImport>): Promise<void> {
console.log("MediaLibrary.onImportAsset", data);
this._mediaFiles = undefined;
this.#importDialogRef.value?.close();
if (!this._uploadedFile)
throw new Error("Expected `_uploadedFile` to be non-null!");
try {
const options: UploadMediaFileOptions = {
mediaClass: data.type,
};
if (data.type === MediaFileClass.Animation)
options.subtype = data.animationSkeleton;
const token = await this._api.uploadMediaFile(this._uploadedFile, options);
await this._api.renameMediaFile(token, data.title);
this._selected.push(token);
}
catch (err) {
if (typeof err === "object" && err !== null && err instanceof Error) {
this._apiError = err;
} else {
this._apiError = new Error(String(err));
}
}
this.loadResults();
}
onAddToScene(): void {
if (this.#canAddAnimation) {
this.#addAnimation();
} else {
this.#spawnSelectedScenes();
}
}
#addAnimation(): void {
// TODO: Make this actually work
console.warn("Adding animations not yet implemented!");
this.dispatchEvent(new CustomEvent("close"));
}
#spawnSelectedScenes(): void {
const assets = this._selected
.map(token => {
if (!this._mediaFiles) {
console.error("Expected `_mediaFiles` map to be present when spawning assets");
return null;
}
const mediaFile = this._mediaFiles.get(token);
if (!mediaFile) {
console.error(`Attempted to spawn an invalid token: "${token}"`);
return null;
}
const ext = mediaFile.extension;
if (!ext) {
console.error("Expected media file to have a non-null `extension` field");
return null;
}
return {
path: `remote://${token}${ext}`,
name: mediaFile.displayName,
};
})
.filter(exists);
setTimeout(() => {
studio.spawnAssets(assets);
});
this.dispatchEvent(new CustomEvent("close"));
}
protected override render = (): TemplateResult => html`
@@ -164,7 +314,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<section class="main">
<nav class="sidebar" part="sidebar">
<ul class="sidebar__list">
${MEDIA_FILE_CATEGORIES.map(cat => html`
${MEDIA_FILE_CATEGORIES.map((cat, idx) => html`
<li class="sidebar__listitem">
<sts-button
class=${classMap({
@@ -172,6 +322,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
})}
.icon=${cat.icon}
title=${ifDefined(cat.tooltip)}
?autofocus=${idx === 0}
?disabled=${cat.disabled}
@click=${() => {
if (!cat.disabled)
@@ -189,7 +340,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
@click=${this.promptForFileUpload}
>
Import...
</sts-button>
@@ -211,6 +362,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
>
${match (this.#resultsStatus, {
[ResultsStatus.Loading]: () => html`
<!-- TODO: Spinner or something -->
<span>Loading...</span>
`,
[ResultsStatus.Empty]: () => html`
@@ -220,7 +372,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
@click=${this.promptForFileUpload}
>
Import...
</sts-button>
@@ -262,6 +414,8 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-primary content__add-to-scene"
icon="plus-large"
?disabled=${!this.#canAddToScene}
@click=${this.onAddToScene}
>
Add to Scene
</sts-button>
@@ -270,8 +424,16 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
</section>
<sts-dialog
.trigger=${this.importTriggers}
.template=${[this, this.importDialogTemplate] as const}
${ref(this.#importDialogRef)}
skipWrapper
.template=${[this, html`
<sts-asset-import-dialog
.file=${this._uploadedFile}
.assetType=${this._activeCategory.mediaClass}
@import-asset=${this.onImportAsset}
@close=${() => this.#importDialogRef.value?.close()}
></sts-asset-import-dialog>
`] as const}
></sts-dialog>
`;
}
@@ -281,6 +443,7 @@ interface MediaFileCategory {
icon: string;
disabled?: boolean;
tooltip?: string;
mediaClass?: MediaFileClass;
predicate: Pred<MediaFile>;
}
@@ -293,6 +456,7 @@ const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
{
name: "Animation",
icon: "film",
mediaClass: MediaFileClass.Animation,
predicate: it => it.mediaClass === MediaFileClass.Animation,
},
{
@@ -300,21 +464,25 @@ const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
icon: "volume",
disabled: true,
tooltip: "Coming soon!",
mediaClass: MediaFileClass.Audio,
predicate: it => it.mediaClass === MediaFileClass.Audio,
},
{
name: "Characters",
icon: "figure",
mediaClass: MediaFileClass.Character,
predicate: it => it.mediaClass === MediaFileClass.Character,
},
{
name: "Props",
icon: "cube",
mediaClass: MediaFileClass.Prop,
predicate: it => it.mediaClass === MediaFileClass.Prop,
},
{
name: "Scenes",
icon: "scene",
mediaClass: MediaFileClass.Scene,
predicate: it => it.mediaClass === MediaFileClass.Scene,
},
];
@@ -335,6 +503,7 @@ class MediaFile {
title?: string;
filename?: string;
extension?: string;
visibility: Visibility;
bucketPath: string;
@@ -354,6 +523,9 @@ class MediaFile {
this.title = info.maybe_title ?? undefined;
this.filename = info.maybe_original_filename ?? undefined;
this.extension = info.maybe_engine_extension
?? this.filename?.match(/\..+/)?.[0]
?? undefined;
this.visibility = info.creator_set_visibility;
this.bucketPath = info.public_bucket_path;
+12 -1
View File
@@ -74,6 +74,16 @@ export class StorytellerApi {
return response.data.media_file_token;
}
async renameMediaFile(token: MediaFileToken, name: string): Promise<void> {
const response = await this.#api.post(`v1/media_files/rename/${token}`, { name });
this.#validateResponse(response);
}
// TODO
async convertFbxToGltf(token: MediaFileToken): Promise<void> {
}
async mediaFile(token: MediaFileToken): Promise<MediaFile> {
const response = await this.#api.get<MediaFileResult>(`v1/media_files/file/${token}`);
this.#validateResponse(response);
@@ -116,7 +126,7 @@ export interface ListMediaFilesOptions {
}
export interface UploadMediaFileOptions {
subtype: MediaFileSubtype;
subtype?: MediaFileSubtype;
mediaClass: MediaFileClass;
}
@@ -152,6 +162,7 @@ export interface MediaFileResult {
export interface MediaFile {
token: MediaFileToken;
media_type: MediaFileType;
maybe_engine_extension?: string;
maybe_media_subtype?: MediaFileSubtype;
media_class: MediaFileClass;
maybe_batch_token?: unknown;
+4 -1
View File
@@ -231,7 +231,10 @@ fn init_from_asset(app: &mut App, asset: InitAsset, skybox_id: String) {
}
InitAsset::ImportedScene(scene) => {
app.add_systems(Startup, move |mut ew: EventWriter<ImportSceneEvent>| {
ew.send(ImportSceneEvent(scene.clone()));
ew.send(ImportSceneEvent {
path: scene.clone(),
name: None,
});
});
}
}
+21 -94
View File
@@ -38,6 +38,9 @@ use crate::{
MainCamera,
};
#[cfg(feature = "wasm")]
mod wasm;
pub struct ScenePlugin;
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -105,7 +108,7 @@ impl Plugin for ScenePlugin {
app.add_systems(
Update,
(
wasm::dispatch_object_load_request,
wasm::handle_spawn_asset_messages,
wasm::notify_state_changes
.map(bevy::utils::error)
.run_if(resource_changed::<State<SceneState>>()),
@@ -247,8 +250,11 @@ pub struct LoadSceneEvent(pub String);
/// Request the application to import and process a glTF scene from an arbitrary
/// asset source (e.g., an http path or a Storyteller.ai media token).
#[derive(Event, Deref)]
pub struct ImportSceneEvent(pub String);
#[derive(Event)]
pub struct ImportSceneEvent {
pub path: String,
pub name: Option<String>,
}
/// A tag that identifies its entity as a user-facing scene element like a prop,
/// a light, or a camera.
@@ -380,16 +386,23 @@ fn init_scene_import(
r_assets: Res<AssetServer>,
mut er: EventReader<ImportSceneEvent>,
) {
if let Some(path) = er.read().last() {
info!("Importing asset: {}", path.0);
for event in er.read() {
info!("Importing asset: {}", event.path);
let name = event
.name
.as_ref()
.map(|name| Name::new(name.clone()))
.unwrap_or_else(|| Name::new("Scene"));
cmd.spawn((
Name::new("Scene"), // TODO: Maybe use the "basename" part of the asset path?
name,
SpatialBundle::default(),
space_editor::prelude::GltfPrefab {
path: path.0.clone(),
path: event.path.clone(),
scene: "Scene0".to_string(),
},
r_assets.load::<Gltf>(&path.0),
r_assets.load::<Gltf>(&event.path),
PrefabMarker,
SceneRoot,
));
@@ -601,89 +614,3 @@ fn start_capture(mut captures: Query<&mut ScreenCapture, Added<ScreenCapture>>)
capture.start_capturing();
}
}
#[cfg(feature = "wasm")]
mod wasm {
use bevy::{prelude::*, window::PrimaryWindow};
use wasm_bindgen::prelude::*;
use super::{LoadLibraryObjectEvent, LoadingQueue, SceneState};
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
static OBJECT_LOAD_REQUEST: WasmMessageBuffer<String> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = loadObject)]
pub fn load_object(id: &str) {
OBJECT_LOAD_REQUEST.write_message(id.into());
}
// FIXME: Is it an object or a scene? Inconsistent terminology is
// unnecessarily confusing
pub(super) fn dispatch_object_load_request(
mut ew_load_scene: EventWriter<LoadLibraryObjectEvent>,
) {
if let Some(id) = OBJECT_LOAD_REQUEST.take_message() {
ew_load_scene.send(LoadLibraryObjectEvent(id));
}
}
#[wasm_bindgen(typescript_custom_section)]
const SCENE_STATE_EVENT: &str = r#"
export interface SceneStateEvent extends CustomEvent {
type: "scene-state";
detail: SceneState;
}
declare global {
export interface GlobalEventHandlersEventMap {
"scene-state": SceneStateEvent;
}
}
"#;
pub(super) fn notify_state_changes(
r_state: Res<State<SceneState>>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if let Ok(window) = q_window.get_single() {
wasm::dispatch_to_js(window, "scene-state", &(**r_state as u32))?;
}
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const LOADING_QUEUE_EVENT: &str = r#"
export interface LoadingQueueEvent extends CustomEvent {
type: "loading-queue";
detail: number;
}
declare global {
export interface GlobalEventHandlersEventMap {
"loading-queue": LoadingQueueEvent;
}
}
"#;
pub(super) fn notify_loading_queue_changes(
mut l_last_sent: Local<Option<usize>>,
r_loading: Res<LoadingQueue>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
let queue_len = r_loading.queue.len();
if let Some(last_sent) = *l_last_sent {
if last_sent == queue_len {
return Ok(());
}
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
wasm::dispatch_to_js(window, "loading-queue", &queue_len)?;
*l_last_sent = Some(queue_len);
Ok(())
}
}
+111
View File
@@ -0,0 +1,111 @@
use bevy::{prelude::*, window::PrimaryWindow};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use super::{ImportSceneEvent, LoadingQueue, SceneState};
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
static SPAWN_ASSETS: WasmMessageBuffer<Vec<AssetImport>> = WasmMessageBuffer::new();
#[wasm_bindgen(typescript_custom_section)]
const ASSET_IMPORT_TS: &str = r#"
export interface AssetImport {
path: string;
name?: string;
}
"#;
#[derive(Deserialize, Serialize)]
struct AssetImport {
path: String,
name: Option<String>,
}
#[wasm_bindgen(typescript_custom_section)]
const SPAWN_ASSET_TS: &str = r#"
export function spawnAsset(asset: AssetImport): void;
"#;
#[wasm_bindgen(js_name = spawnAsset, skip_typescript)]
pub fn spawn_asset(asset: JsValue) -> Result<(), JsValue> {
let asset: AssetImport = serde_wasm_bindgen::from_value(asset)?;
SPAWN_ASSETS.write_message(vec![asset]);
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const SPAWN_ASSETS_TS: &str = r#"
export function spawnAssets(assets: AssetImport[]): void;
"#;
#[wasm_bindgen(js_name = spawnAssets, skip_typescript)]
pub fn spawn_assets(assets: JsValue) -> Result<(), JsValue> {
let assets: Vec<AssetImport> = serde_wasm_bindgen::from_value(assets)?;
SPAWN_ASSETS.write_message(assets);
Ok(())
}
pub(super) fn handle_spawn_asset_messages(mut ew_import_scene: EventWriter<ImportSceneEvent>) {
if let Some(assets) = SPAWN_ASSETS.take_message() {
for AssetImport { path, name } in assets {
ew_import_scene.send(ImportSceneEvent { path, name });
}
}
}
#[wasm_bindgen(typescript_custom_section)]
const SCENE_STATE_EVENT: &str = r#"
export interface SceneStateEvent extends CustomEvent {
type: "scene-state";
detail: SceneState;
}
declare global {
export interface GlobalEventHandlersEventMap {
"scene-state": SceneStateEvent;
}
}
"#;
pub(super) fn notify_state_changes(
r_state: Res<State<SceneState>>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if let Ok(window) = q_window.get_single() {
wasm::dispatch_to_js(window, "scene-state", &(**r_state as u32))?;
}
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const LOADING_QUEUE_EVENT: &str = r#"
export interface LoadingQueueEvent extends CustomEvent {
type: "loading-queue";
detail: number;
}
declare global {
export interface GlobalEventHandlersEventMap {
"loading-queue": LoadingQueueEvent;
}
}
"#;
pub(super) fn notify_loading_queue_changes(
mut l_last_sent: Local<Option<usize>>,
r_loading: Res<LoadingQueue>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
let queue_len = r_loading.queue.len();
if let Some(last_sent) = *l_last_sent {
if last_sent == queue_len {
return Ok(());
}
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
wasm::dispatch_to_js(window, "loading-queue", &queue_len)?;
*l_last_sent = Some(queue_len);
Ok(())
}