From b905a0f63592c236aa6e6fa31ec0e07216c0a6c1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 27 Mar 2024 16:41:21 -0400 Subject: [PATCH 1/4] Install plugin for ShortMaterial in headless mode (#105) Not for review. This is just to get an artifact to run in production. There's probably a better way to organize this **NB:** We don't need to keep compatibility with the `type_path`. I'm only doing this to stay compatible with files being written in production currently. I assume this might change. --- studio/src/lib.rs | 3 ++ studio/src/material/mod.rs | 37 ++++++++++++++++++++++++ studio/src/material/short_material.rs | 25 ++++++++++++++++ studio/src/ui/inspector.rs | 41 +-------------------------- 4 files changed, 66 insertions(+), 40 deletions(-) create mode 100644 studio/src/material/mod.rs create mode 100644 studio/src/material/short_material.rs diff --git a/studio/src/lib.rs b/studio/src/lib.rs index 6eff445..aa0c9e1 100644 --- a/studio/src/lib.rs +++ b/studio/src/lib.rs @@ -21,6 +21,7 @@ mod hierarchy; mod input; mod inspector; mod interaction; +pub mod material; pub mod math; mod mixamo; mod path; @@ -33,6 +34,7 @@ mod ui; pub mod viewport; mod world_grid; +use crate::material::MaterialPlugin; pub use crate::{ anim::AnimPlugin, bvh::BvhPlugin, @@ -86,6 +88,7 @@ impl PluginGroup for CommonPlugins { .add(InspectorPlugin) .add(InterpolationPlugin) .add(ScenePlugin) + .add(MaterialPlugin) .add(SkyboxPlugin) .add(StudioInputPlugin) .add(UiLayerPlugin) diff --git a/studio/src/material/mod.rs b/studio/src/material/mod.rs new file mode 100644 index 0000000..3088b26 --- /dev/null +++ b/studio/src/material/mod.rs @@ -0,0 +1,37 @@ +use crate::material::short_material::ShortMaterial; +use bevy::app::{App, Plugin, PostUpdate}; +use bevy::asset::{Assets, Handle}; +use bevy::pbr::StandardMaterial; +use bevy::prelude::{Commands, Entity, Query, ResMut}; +use space_editor::prelude::EditorRegistryExt; + +pub mod short_material; + +pub struct MaterialPlugin; +impl Plugin for MaterialPlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .editor_registry::(); + + app.add_systems(PostUpdate, apply_short_materials); + } +} + +fn apply_short_materials( + mut commands: Commands, + q_mats: Query<(Entity, &Handle, &ShortMaterial)>, + mut materials: ResMut>, +) { + for (entity, handle, short) in q_mats.iter() { + let Some(material) = materials.get_mut(handle) else { + continue; + }; + + material.base_color = short.base_color; + material.perceptual_roughness = short.perceptual_roughness; + material.metallic = short.metallic; + material.reflectance = short.reflectance; + + commands.entity(entity).remove::(); + } +} diff --git a/studio/src/material/short_material.rs b/studio/src/material/short_material.rs new file mode 100644 index 0000000..9ba7706 --- /dev/null +++ b/studio/src/material/short_material.rs @@ -0,0 +1,25 @@ +use bevy::pbr::StandardMaterial; +use bevy::prelude::{Color, Component, Reflect}; + +use bevy::ecs::reflect::ReflectComponent; +use bevy::reflect::std_traits::ReflectDefault; + +#[derive(Reflect, Default, Clone, Component)] +#[reflect(Default, Component)] +pub struct ShortMaterial { + pub base_color: Color, + pub perceptual_roughness: f32, + pub metallic: f32, + pub reflectance: f32, +} + +impl ShortMaterial { + pub fn new(material: &StandardMaterial) -> Self { + Self { + base_color: material.base_color, + perceptual_roughness: material.perceptual_roughness, + metallic: material.metallic, + reflectance: material.reflectance, + } + } +} diff --git a/studio/src/ui/inspector.rs b/studio/src/ui/inspector.rs index 5d6f3df..7cac5f5 100644 --- a/studio/src/ui/inspector.rs +++ b/studio/src/ui/inspector.rs @@ -20,6 +20,7 @@ use space_editor::{ }; use wasm_bindgen::prelude::*; +use crate::material::short_material::ShortMaterial; use crate::{ interaction::Selection, wasm::{self, StaticBuffer, WasmMessageBuffer}, @@ -94,7 +95,6 @@ impl Plugin for InspectorPlugin { .chain() .before(space_editor::space_prefab::sub_scene::prepare_auto_scene), ); - app.add_systems(PostUpdate, apply_short_materials); app.world.resource_mut::().getters.insert( StandardMaterial::get_type_registration() @@ -142,25 +142,6 @@ fn create_short_materials( } } -fn apply_short_materials( - mut commands: Commands, - q_mats: Query<(Entity, &Handle, &ShortMaterial)>, - mut materials: ResMut>, -) { - for (entity, handle, short) in q_mats.iter() { - let Some(material) = materials.get_mut(handle) else { - continue; - }; - - material.base_color = short.base_color; - material.perceptual_roughness = short.perceptual_roughness; - material.metallic = short.metallic; - material.reflectance = short.reflectance; - - commands.entity(entity).remove::(); - } -} - #[derive(Resource, Default)] struct InspectGetters { getters: HashMap< @@ -591,26 +572,6 @@ fn auto_serialize_asset( ev_update_description.send(UpdateEntityDescription); } -#[derive(Reflect, Default, Clone, Component)] -#[reflect(Default, Component)] -struct ShortMaterial { - pub base_color: Color, - pub perceptual_roughness: f32, - pub metallic: f32, - pub reflectance: f32, -} - -impl ShortMaterial { - pub fn new(material: &StandardMaterial) -> Self { - Self { - base_color: material.base_color, - perceptual_roughness: material.perceptual_roughness, - metallic: material.metallic, - reflectance: material.reflectance, - } - } -} - fn serialize_standart_materal( r_selection: Option>, q_m: Query<&Handle>, From 1163192d24c90031a323e4bc12b8562f1b22be2b Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Wed, 27 Mar 2024 19:20:33 -0400 Subject: [PATCH 2/4] 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. --- framework/src/lib/decorators/on.ts | 6 +- studio-frontend/src/app/app.element.ts | 9 +- .../src/lib/dialog/dialog-base.element.scss | 20 +- .../src/lib/dialog/dialog-base.element.ts | 51 +++- .../src/lib/dialog/dialog-footer.element.scss | 13 + .../src/lib/dialog/dialog-footer.element.ts | 16 ++ .../src/lib/dialog/dialog-header.element.scss | 8 +- .../src/lib/dialog/dialog-header.element.ts | 24 ++ studio-ui/src/lib/dialog/dialog.element.ts | 21 +- studio-ui/src/lib/dialog/index.ts | 2 + studio-ui/src/lib/forms/text-field.element.ts | 9 +- studio-ui/src/lib/icon.element.ts | 4 +- .../src/lib/asset-import-dialog.element.scss | 60 +++++ .../src/lib/asset-import-dialog.element.ts | 231 ++++++++++++++++++ .../src/lib/media-library-dialog.element.ts | 214 ++++++++++++++-- studio-web/src/lib/storyteller-api.ts | 13 +- studio/src/lib.rs | 5 +- studio/src/{scene.rs => scene/mod.rs} | 115 ++------- studio/src/scene/wasm.rs | 111 +++++++++ 19 files changed, 792 insertions(+), 140 deletions(-) create mode 100644 studio-ui/src/lib/dialog/dialog-footer.element.scss create mode 100644 studio-ui/src/lib/dialog/dialog-footer.element.ts create mode 100644 studio-web/src/lib/asset-import-dialog.element.scss create mode 100644 studio-web/src/lib/asset-import-dialog.element.ts rename studio/src/{scene.rs => scene/mod.rs} (86%) create mode 100644 studio/src/scene/wasm.rs 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(()) +} From da152b85852e61d54451dc364e146e1f47eddce9 Mon Sep 17 00:00:00 2001 From: Lee-Orr Date: Thu, 28 Mar 2024 11:25:02 -0400 Subject: [PATCH 3/4] Initial restyle (#111) ![image](https://github.com/storytold/storyteller-bevy/assets/10376144/b8078ef3-8d36-433d-b75b-5c410eec7b85) --- .../src/app/anim-editor.element.scss | 26 +- .../src/app/anim-editor.element.ts | 18 +- studio-frontend/src/app/app.element.scss | 238 ++++++++++-------- studio-frontend/src/app/app.element.ts | 188 ++++++-------- studio-frontend/src/styles.scss | 13 + studio-ui/src/lib/toolbar-group.element.scss | 2 +- .../inspector/entity-inspector.element.scss | 15 +- .../lib/inspector/entity-inspector.element.ts | 112 +++++---- .../src/lib/transform-toolbar.element.scss | 7 +- 9 files changed, 334 insertions(+), 285 deletions(-) diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss index 07a10c2..000266b 100644 --- a/studio-frontend/src/app/anim-editor.element.scss +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -1,8 +1,30 @@ :host { + display: grid; + grid-template-rows: subgrid; + grid-template-columns: subgrid; +} + +.timeline-controls { + align-self: end; + justify-self: center; + display: flex; + justify-content: center; + grid-column: center-toolbar; + grid-row: bottom-toolbar; + background-color: var(--color-toolbar-background); + padding: var(--padding); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: var(--corner-radius); + border-top-left-radius: var(--corner-radius); + +} + +.timeline-wrapper { + grid-area: timeline; display: grid; border-radius: 8px; - background: #0006; - backdrop-filter: blur(16px); + background: var(--color-panel-background); grid-template-columns: [sidebar-start] min-content [sidebar-end divider-start] 8px [divider-end diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index 22d18f0..e790518 100644 --- a/studio-frontend/src/app/anim-editor.element.ts +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -323,14 +323,9 @@ export class AnimEditorElement extends LitElement const none_selected = !selected; return html` -