mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-18 07:35:19 +00:00
1163192d24
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.
536 lines
13 KiB
TypeScript
536 lines
13 KiB
TypeScript
import * as studio from "@storyteller/studio";
|
|
import { SceneElement } from "@storyteller/studio";
|
|
import { bind, inject } from "@storyteller/framework";
|
|
import { DialogBaseElement, DialogElement } from "@storyteller/studio-ui/dialog";
|
|
import {
|
|
type MediaFile as IMediaFile,
|
|
MediaFileClass,
|
|
MediaFileSubtype,
|
|
MediaFileToken,
|
|
MediaFileType,
|
|
StorytellerApi,
|
|
Visibility,
|
|
UploadMediaFileOptions,
|
|
SCENE_DATA_PROVIDER,
|
|
type SceneDataProvider,
|
|
} from "@storyteller/studio-web";
|
|
import { exists, match, type Pred } from "@storyteller/utility";
|
|
import {
|
|
html,
|
|
nothing,
|
|
type PropertyValues,
|
|
TemplateResult,
|
|
unsafeCSS,
|
|
css,
|
|
} from "lit";
|
|
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";
|
|
|
|
@customElement("sts-media-library-dialog")
|
|
export class MediaLibraryDialogElement extends DialogBaseElement {
|
|
static override styles = css`
|
|
${DialogBaseElement.styles}
|
|
${unsafeCSS(styles)}
|
|
`;
|
|
|
|
override heading = "Media Library";
|
|
|
|
@state() _mediaFiles?: Map<MediaFileToken, MediaFile>;
|
|
@state() _searchTerm = "";
|
|
@state() _selected: MediaFileToken[] = [];
|
|
@state() _activeCategory: MediaFileCategory = MEDIA_FILE_CATEGORIES[0];
|
|
@state() _filters: Pred<MediaFile>[] = [];
|
|
@state() _apiError?: Error;
|
|
|
|
@inject(StorytellerApi)
|
|
_api!: StorytellerApi;
|
|
|
|
@inject(SCENE_DATA_PROVIDER)
|
|
_sceneData!: SceneDataProvider;
|
|
|
|
#dateFmt = new Intl.DateTimeFormat("en-us");
|
|
#importDialogRef = createRef<DialogElement>();
|
|
#transientFileInput?: HTMLInputElement;
|
|
|
|
@state() _uploadedFile?: File;
|
|
|
|
get #mediaFiles() {
|
|
if (!this._mediaFiles)
|
|
return null;
|
|
|
|
return Array.from(this._mediaFiles.values()).filter(it =>
|
|
this._filters.every(pred => pred(it))
|
|
);
|
|
}
|
|
|
|
get #resultsStatus() {
|
|
if (this._apiError)
|
|
return ResultsStatus.Error;
|
|
if (!this.#mediaFiles)
|
|
return ResultsStatus.Loading;
|
|
if (!this.#mediaFiles.length)
|
|
return ResultsStatus.Empty;
|
|
|
|
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 = [
|
|
this._activeCategory.predicate,
|
|
it => it.displayName.includes(this._searchTerm),
|
|
];
|
|
}
|
|
|
|
super.willUpdate(changes);
|
|
}
|
|
|
|
async loadResults(): Promise<void> {
|
|
this._mediaFiles = undefined;
|
|
this._apiError = undefined;
|
|
|
|
try {
|
|
const session = await this._api.session();
|
|
if (!session.logged_in || !session.user?.username)
|
|
throw new Error("Not logged in");
|
|
|
|
const response = await this._api.listMediaFiles({
|
|
username: session.user.username,
|
|
filterMediaType: [
|
|
MediaFileType.Bvh,
|
|
MediaFileType.Glb,
|
|
MediaFileType.Gltf,
|
|
MediaFileType.ScnRon,
|
|
],
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
response.results.map(({ token }) => this._api.mediaFile(token))
|
|
);
|
|
console.log("Results:", results);
|
|
|
|
this._mediaFiles = new Map(
|
|
results.map(info => [info.token, new MediaFile(info)])
|
|
);
|
|
}
|
|
catch (err) {
|
|
if (typeof err === "object" && err !== null && err instanceof Error) {
|
|
this._apiError = err;
|
|
} else {
|
|
this._apiError = new Error(String(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
onSelect(token: MediaFileToken) {
|
|
return (event: PointerEvent) => {
|
|
event.stopPropagation();
|
|
|
|
if (event.getModifierState("Control") || event.getModifierState("Shift"))
|
|
this.toggleSelected(token);
|
|
else
|
|
this.select(token);
|
|
}
|
|
}
|
|
|
|
select(token: MediaFileToken): void {
|
|
this._selected = [token];
|
|
}
|
|
|
|
toggleSelected(token: MediaFileToken): void {
|
|
if (this._selected.includes(token))
|
|
this._selected = this._selected.filter(it => it !== token);
|
|
else
|
|
this._selected = this._selected.concat(token);
|
|
}
|
|
|
|
clearSelection(): void {
|
|
this._selected = [];
|
|
}
|
|
|
|
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;
|
|
|
|
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`
|
|
<sts-dialog-header icon="photo-film-music">
|
|
${this.heading}
|
|
</sts-dialog-header>
|
|
|
|
<section class="main">
|
|
<nav class="sidebar" part="sidebar">
|
|
<ul class="sidebar__list">
|
|
${MEDIA_FILE_CATEGORIES.map((cat, idx) => html`
|
|
<li class="sidebar__listitem">
|
|
<sts-button
|
|
class=${classMap({
|
|
active: cat === this._activeCategory,
|
|
})}
|
|
.icon=${cat.icon}
|
|
title=${ifDefined(cat.tooltip)}
|
|
?autofocus=${idx === 0}
|
|
?disabled=${cat.disabled}
|
|
@click=${() => {
|
|
if (!cat.disabled)
|
|
this._activeCategory = cat;
|
|
}}
|
|
>
|
|
${cat.name}
|
|
</sts-button>
|
|
</li>
|
|
`)}
|
|
</ul>
|
|
</nav>
|
|
<section class="content">
|
|
<div class="content__ui">
|
|
<sts-button
|
|
class="btn-secondary"
|
|
icon="import"
|
|
@click=${this.promptForFileUpload}
|
|
>
|
|
Import...
|
|
</sts-button>
|
|
<sts-text-field
|
|
class="search-field"
|
|
type="search"
|
|
placeholder="Search"
|
|
icon-pre="search"
|
|
.value=${bind(this, "_searchTerm")}
|
|
></sts-text-field>
|
|
</div>
|
|
<div
|
|
class=${classMap({
|
|
"content__results": true,
|
|
"content__results--centered": this.#resultsStatus !== ResultsStatus.NonEmpty,
|
|
"content__results--grid": this.#resultsStatus === ResultsStatus.NonEmpty,
|
|
})}
|
|
@click=${this.clearSelection}
|
|
>
|
|
${match (this.#resultsStatus, {
|
|
[ResultsStatus.Loading]: () => html`
|
|
<!-- TODO: Spinner or something -->
|
|
<span>Loading...</span>
|
|
`,
|
|
[ResultsStatus.Empty]: () => html`
|
|
<div class="placeholder">
|
|
<h2 class="heading">No results found</h2>
|
|
<p>Import a new asset to get started!</p>
|
|
<sts-button
|
|
class="btn-secondary"
|
|
icon="import"
|
|
@click=${this.promptForFileUpload}
|
|
>
|
|
Import...
|
|
</sts-button>
|
|
</div>
|
|
`,
|
|
[ResultsStatus.Error]: () => html`
|
|
<div class="error">
|
|
<h2 class="heading">Error</h2>
|
|
<p>${this._apiError!.message}</p>
|
|
<sts-button
|
|
class="btn-secondary"
|
|
icon="rotate"
|
|
@click=${this.loadResults}
|
|
>
|
|
Retry
|
|
</sts-button>
|
|
</div>
|
|
`,
|
|
[ResultsStatus.NonEmpty]: () => this.#mediaFiles!.map(file => html`
|
|
<div
|
|
class=${classMap({
|
|
"content__media-file": true,
|
|
"selected": this._selected.includes(file.token),
|
|
})}
|
|
@click=${this.onSelect(file.token)}
|
|
tabindex="0"
|
|
>
|
|
<span class="last-updated">
|
|
${this.#dateFmt.format(file.updated)}
|
|
</span>
|
|
<h4 class="name">
|
|
${file.displayName}
|
|
</h4>
|
|
</div>
|
|
`),
|
|
})}
|
|
</div>
|
|
${this._selected.length > 0 ? html`
|
|
<sts-button
|
|
class="btn-primary content__add-to-scene"
|
|
icon="plus-large"
|
|
?disabled=${!this.#canAddToScene}
|
|
@click=${this.onAddToScene}
|
|
>
|
|
Add to Scene
|
|
</sts-button>
|
|
` : nothing}
|
|
</section>
|
|
</section>
|
|
|
|
<sts-dialog
|
|
${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>
|
|
`;
|
|
}
|
|
|
|
interface MediaFileCategory {
|
|
name: string;
|
|
icon: string;
|
|
disabled?: boolean;
|
|
tooltip?: string;
|
|
mediaClass?: MediaFileClass;
|
|
predicate: Pred<MediaFile>;
|
|
}
|
|
|
|
const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
|
|
{
|
|
name: "Home",
|
|
icon: "home",
|
|
predicate: () => true,
|
|
},
|
|
{
|
|
name: "Animation",
|
|
icon: "film",
|
|
mediaClass: MediaFileClass.Animation,
|
|
predicate: it => it.mediaClass === MediaFileClass.Animation,
|
|
},
|
|
{
|
|
name: "Audio",
|
|
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,
|
|
},
|
|
];
|
|
|
|
enum ResultsStatus {
|
|
Loading,
|
|
Empty,
|
|
NonEmpty,
|
|
Error,
|
|
}
|
|
|
|
class MediaFile {
|
|
token: MediaFileToken;
|
|
|
|
type: MediaFileType;
|
|
subtype?: MediaFileSubtype;
|
|
mediaClass: MediaFileClass;
|
|
|
|
title?: string;
|
|
filename?: string;
|
|
extension?: string;
|
|
|
|
visibility: Visibility;
|
|
bucketPath: string;
|
|
created: Date;
|
|
updated: Date;
|
|
|
|
get displayName(): string {
|
|
return this.title ?? this.filename ?? this.token;
|
|
}
|
|
|
|
constructor (info: IMediaFile) {
|
|
this.token = info.token;
|
|
|
|
this.type = info.media_type;
|
|
this.subtype = info.maybe_media_subtype ?? undefined;
|
|
this.mediaClass = info.media_class;
|
|
|
|
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;
|
|
this.created = new Date(info.created_at);
|
|
this.updated = new Date(info.updated_at);
|
|
}
|
|
}
|