diff --git a/framework/src/lib/decorators/on.ts b/framework/src/lib/decorators/on.ts index c3a66a1..2867564 100644 --- a/framework/src/lib/decorators/on.ts +++ b/framework/src/lib/decorators/on.ts @@ -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); } }); diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index b7e55a8..ecac5a8 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -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` - + @@ -231,6 +231,7 @@ export class AppElement extends LitElement { @@ -318,9 +319,9 @@ export class AppElement extends LitElement { ${this.loading ? html` ` : nothing} + - `; } } diff --git a/studio-ui/src/lib/dialog/dialog-base.element.scss b/studio-ui/src/lib/dialog/dialog-base.element.scss index 05a4edd..a7ff456 100644 --- a/studio-ui/src/lib/dialog/dialog-base.element.scss +++ b/studio-ui/src/lib/dialog/dialog-base.element.scss @@ -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; + } } } diff --git a/studio-ui/src/lib/dialog/dialog-base.element.ts b/studio-ui/src/lib/dialog/dialog-base.element.ts index 7d9e7a5..10d4626 100644 --- a/studio-ui/src/lib/dialog/dialog-base.element.ts +++ b/studio-ui/src/lib/dialog/dialog-base.element.ts @@ -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): 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` -
+
- + `; } } diff --git a/studio-ui/src/lib/dialog/dialog-footer.element.scss b/studio-ui/src/lib/dialog/dialog-footer.element.scss new file mode 100644 index 0000000..26b9495 --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-footer.element.scss @@ -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; + } +} diff --git a/studio-ui/src/lib/dialog/dialog-footer.element.ts b/studio-ui/src/lib/dialog/dialog-footer.element.ts new file mode 100644 index 0000000..25554ed --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-footer.element.ts @@ -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` + + `; +} diff --git a/studio-ui/src/lib/dialog/dialog-header.element.scss b/studio-ui/src/lib/dialog/dialog-header.element.scss index dcb827a..b7918e8 100644 --- a/studio-ui/src/lib/dialog/dialog-header.element.scss +++ b/studio-ui/src/lib/dialog/dialog-header.element.scss @@ -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; diff --git a/studio-ui/src/lib/dialog/dialog-header.element.ts b/studio-ui/src/lib/dialog/dialog-header.element.ts index 68160b1..ff3901b 100644 --- a/studio-ui/src/lib/dialog/dialog-header.element.ts +++ b/studio-ui/src/lib/dialog/dialog-header.element.ts @@ -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`
${this.icon ? html` { - if (!this.ignoreBackdropClick) + if (!this.ignoreBackdropClick && !this.nonBlocking) this.close(); } } diff --git a/studio-ui/src/lib/dialog/index.ts b/studio-ui/src/lib/dialog/index.ts index 8cb5302..f39b08b 100644 --- a/studio-ui/src/lib/dialog/index.ts +++ b/studio-ui/src/lib/dialog/index.ts @@ -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"; diff --git a/studio-ui/src/lib/forms/text-field.element.ts b/studio-ui/src/lib/forms/text-field.element.ts index e241c32..1ebfecc 100644 --- a/studio-ui/src/lib/forms/text-field.element.ts +++ b/studio-ui/src/lib/forms/text-field.element.ts @@ -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): void { + if (changes.has("value")) + this.internals.setFormValue(this.value); + + super.update(changes); + } + @on("keydown") @on("keyup") blockKeypressBubbling(event: KeyboardEvent): void { diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 72307a2..a5610e9 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -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, }); diff --git a/studio-web/src/lib/asset-import-dialog.element.scss b/studio-web/src/lib/asset-import-dialog.element.scss new file mode 100644 index 0000000..3f36a26 --- /dev/null +++ b/studio-web/src/lib/asset-import-dialog.element.scss @@ -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; + } +} diff --git a/studio-web/src/lib/asset-import-dialog.element.ts b/studio-web/src/lib/asset-import-dialog.element.ts new file mode 100644 index 0000000..14bb63e --- /dev/null +++ b/studio-web/src/lib/asset-import-dialog.element.ts @@ -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(); + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("file") && !this._title) { + this._title = this.file?.name.match(/^(.+?)\./)?.[1] ?? ""; + } + + super.willUpdate(changes); + } + + protected override updated(changes: PropertyValues): 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("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` + + + Import Asset + + +
+
+ + +
+
+ + +
+ ${this.assetType === MediaFileClass.Animation ? html` + + + ` : nothing} +
+ + + { + this.#formRef.value?.requestSubmit(); + }} + > + Upload + + +
+ `; +} + +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 + } +} diff --git a/studio-web/src/lib/media-library-dialog.element.ts b/studio-web/src/lib/media-library-dialog.element.ts index 958fa35..4c46841 100644 --- a/studio-web/src/lib/media-library-dialog.element.ts +++ b/studio-web/src/lib/media-library-dialog.element.ts @@ -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; @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(); + #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>) => { + if (changes.has("selectedObject")) + this.requestUpdate(); + } + protected override willUpdate(changes: PropertyValues): 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` - - Import Asset - + 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; -

Lorem ipsum dolor sit amet

- `; + 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): Promise { + 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 {
this.#importDialogRef.value?.close()} + > + `] as const} > `; } @@ -281,6 +443,7 @@ interface MediaFileCategory { icon: string; disabled?: boolean; tooltip?: string; + mediaClass?: MediaFileClass; predicate: Pred; } @@ -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; diff --git a/studio-web/src/lib/storyteller-api.ts b/studio-web/src/lib/storyteller-api.ts index c62ebbb..2df7959 100644 --- a/studio-web/src/lib/storyteller-api.ts +++ b/studio-web/src/lib/storyteller-api.ts @@ -74,6 +74,16 @@ export class StorytellerApi { return response.data.media_file_token; } + async renameMediaFile(token: MediaFileToken, name: string): Promise { + const response = await this.#api.post(`v1/media_files/rename/${token}`, { name }); + this.#validateResponse(response); + } + + // TODO + async convertFbxToGltf(token: MediaFileToken): Promise { + + } + async mediaFile(token: MediaFileToken): Promise { const response = await this.#api.get(`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; diff --git a/studio/src/lib.rs b/studio/src/lib.rs index aa0c9e1..364fa6f 100644 --- a/studio/src/lib.rs +++ b/studio/src/lib.rs @@ -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| { - ew.send(ImportSceneEvent(scene.clone())); + ew.send(ImportSceneEvent { + path: scene.clone(), + name: None, + }); }); } } diff --git a/studio/src/scene.rs b/studio/src/scene/mod.rs similarity index 86% rename from studio/src/scene.rs rename to studio/src/scene/mod.rs index 26652e1..fc372dc 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene/mod.rs @@ -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::>()), @@ -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, +} /// 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, mut er: EventReader, ) { - 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::(&path.0), + r_assets.load::(&event.path), PrefabMarker, SceneRoot, )); @@ -601,89 +614,3 @@ fn start_capture(mut captures: Query<&mut ScreenCapture, Added>) 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 = 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, - ) { - 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>, - q_window: Query<&Window, With>, - ) -> 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>, - r_loading: Res, - q_window: Query<&Window, With>, - ) -> 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(()) - } -} diff --git a/studio/src/scene/wasm.rs b/studio/src/scene/wasm.rs new file mode 100644 index 0000000..1217c97 --- /dev/null +++ b/studio/src/scene/wasm.rs @@ -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> = 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, +} + +#[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 = serde_wasm_bindgen::from_value(assets)?; + SPAWN_ASSETS.write_message(assets); + Ok(()) +} + +pub(super) fn handle_spawn_asset_messages(mut ew_import_scene: EventWriter) { + 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>, + q_window: Query<&Window, With>, +) -> 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>, + r_loading: Res, + q_window: Query<&Window, With>, +) -> 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(()) +}