diff --git a/package-lock.json b/package-lock.json index 9d13de8..024ae5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "license": "MIT", "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" }, "devDependencies": { "@nx/js": "16.9.1", @@ -4976,8 +4976,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -4992,12 +4991,11 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", - "dev": true, + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5606,7 +5604,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5950,7 +5947,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6531,10 +6527,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "dev": true, + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -6591,7 +6586,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8041,7 +8035,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -8050,7 +8043,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -8834,8 +8826,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -10202,6 +10193,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 65ddb8a..e2e5c35 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,11 @@ }, "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" } } diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 5d05551..5ad99a2 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -165,3 +165,9 @@ sts-button.active { } } } + +// TODO: Move this +.menu-button-icon { + font-size: 12px; + opacity: 0.75; +} diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 9212f25..737d2eb 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,4 +1,4 @@ -import { drag, on } from "@storyteller/framework"; +import { drag, on, provide } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { type SceneSavedEvent, @@ -6,8 +6,10 @@ import { SceneState, StudioMode, } from "@storyteller/studio"; +import { ButtonElement } from "@storyteller/studio-ui/button"; import { SceneHierarchyElement, + StorytellerApi, StudioURLParams, } from "@storyteller/studio-web"; import { match } from "@storyteller/utility"; @@ -16,12 +18,14 @@ import { customElement, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { createRef, ref } from "lit/directives/ref.js"; import { styleMap } from "lit/directives/style-map.js"; -import { v4 as uuid } from "uuid"; +import { MediaLibraryElement } from "./media-library.element"; import "@storyteller/studio-ui/button"; import "@storyteller/studio-ui/icon"; +import "@storyteller/studio-ui/overlay-provider"; import "@storyteller/studio-ui/toolbar"; import "@storyteller/studio-ui/tree"; +import "@storyteller/studio-web/add-to-scene-menu"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/entity-inspector"; import "@storyteller/studio-web/iframe-messaging-bridge"; @@ -33,10 +37,15 @@ import "./anim-editor.element"; import "./help.element"; import "./blocking.element"; +import "./media-library.element"; import "./app.element.scss"; @customElement("app-root") +@provide({ + token: StorytellerApi, + provide() { return this.api; } +}) export class AppElement extends LitElement { @state() showHelp = false; @state() showInspector = false; @@ -46,8 +55,11 @@ export class AppElement extends LitElement { @state() inspectorWidth = 320; @state() hierarchyHeight?: number; + private api = new StorytellerApi(); #enableAnimTimeline = false; // TODO + #addToSceneButtonRef = createRef(); + #mediaLibraryRef = createRef(); #inspectorRef = createRef(); #hierarchyRef = createRef(); @@ -59,34 +71,15 @@ export class AppElement extends LitElement { @on("scene-saved") async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { - const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); - const formData = new FormData(); - - formData.append("uuid_idempotency_token", uuid()); - formData.append("file", file); - try { - const response = await fetch("https://api.fakeyou.com/v1/engine/create_scene", { - method: "POST", - credentials: "include", - headers: { "Accept": "text/plain" }, - body: formData, - }); + const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); + const token = await this.api.createScene(file); - if (response.ok) { - const resultJson: { - success: boolean; - media_file_token: string; - } = await response.json(); - - if (resultJson.success) { - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: resultJson.media_file_token, - bubbles: true, - composed: true, - })); - } - } + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: token, + bubbles: true, + composed: true, + })); } catch (err) { console.error(err); @@ -145,6 +138,7 @@ export class AppElement extends LitElement { } protected override render = () => html` + @@ -174,6 +168,16 @@ export class AppElement extends LitElement { > Save + + Add to Scene + + ` : nothing} ${this.#params.mode === StudioMode.Editor ? html` - + { + this.#mediaLibraryRef.value?.open(); + }} + > + @@ -276,5 +286,6 @@ export class AppElement extends LitElement { ` : nothing} + `; } diff --git a/studio-frontend/src/app/media-library.element.scss b/studio-frontend/src/app/media-library.element.scss new file mode 100644 index 0000000..cd71d90 --- /dev/null +++ b/studio-frontend/src/app/media-library.element.scss @@ -0,0 +1,14 @@ +:host { + display: block; + min-width: 512px; + min-height: 256px; + max-width: 80%; + max-height: 80%; + padding: 1rem; + overflow: auto; + + background: #0006; + color: #FFFC; + backdrop-filter: blur(16px); + border-radius: 0.5rem; +} diff --git a/studio-frontend/src/app/media-library.element.ts b/studio-frontend/src/app/media-library.element.ts new file mode 100644 index 0000000..8fc5bea --- /dev/null +++ b/studio-frontend/src/app/media-library.element.ts @@ -0,0 +1,131 @@ +import { inject, on } from "@storyteller/framework"; +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "@storyteller/studio-ui/overlay-provider"; +import { + type MediaFile, + MediaFileType, + StorytellerApi, +} from "@storyteller/studio-web"; +import { LitElement, unsafeCSS, type PropertyValues, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { until } from "lit/directives/until.js"; + +import dialogStyles from "./media-library.element.scss?inline"; + +@customElement("sts-media-library") +export class MediaLibraryElement extends LitElement { + @property({ attribute: false }) + trigger = createRef(); + + @inject(OVERLAY_PROVIDER) + overlay!: OverlayProvider; + + #overlayHandle?: WeakRef; + #dialogRef = createRef(); + + open(): void { + this.#overlayHandle = this.overlay.attach(this, html` + this.close()} + > + `, { + containerStyles: { + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + }); + } + + close(): void { + if (this.#overlayHandle) + this.overlay.detach(this.#overlayHandle); + } + + protected override updated(changes: PropertyValues): void { + if (changes.has("trigger")) { + const prevTrigger = changes.get("trigger"); + if (prevTrigger?.value) { + prevTrigger.value.removeEventListener("click", this.onTriggerClick); + } + + const trigger = this.trigger.value; + if (!trigger) return; + + trigger.addEventListener("click", this.onTriggerClick); + } + + super.updated(changes); + } + + override disconnectedCallback(): void { + this.trigger.value?.removeEventListener("click", this.onTriggerClick); + super.disconnectedCallback(); + } + + onTriggerClick = (): void => { + this.open(); + } +} + +@customElement("sts-media-library-dialog") +export class MediaLibraryDialogElement extends LitElement { + static override styles = unsafeCSS(dialogStyles); + + @property({ reflect: true }) + override role = "dialog"; + + @state() + mediaFiles?: Promise; + + @inject(StorytellerApi) + _api!: StorytellerApi; + + override async connectedCallback(): Promise { + this.mediaFiles = this._api + .session() + .then(session => { + if (!session.logged_in || !session.user?.username) + return Promise.reject(new Error("Not logged in")); + + return this._api.listMediaFiles({ + username: session.user.username, + filterMediaType: [ + MediaFileType.Bvh, + MediaFileType.Glb, + MediaFileType.Gltf, + MediaFileType.ScnRon, + ], + }); + }) + .then(result => Promise.all( + result.results.map(({ token }) => this._api.mediaFile(token)) + )) + .catch(err => { + console.error(err); + return []; + }); + + super.connectedCallback(); + } + + @on("window:click") + onWindowClick(event: PointerEvent): void { + if (!event.composedPath().includes(this)) + this.dispatchEvent(new CustomEvent("close")); + } + + protected override render = () => html` + ${until( + this.mediaFiles?.then(files => html` +
${JSON.stringify(files, null, "  ")}
+ `), + html`Loading...` + )} + `; +} diff --git a/studio-frontend/tsconfig.json b/studio-frontend/tsconfig.json index 724ccfc..a8f3853 100644 --- a/studio-frontend/tsconfig.json +++ b/studio-frontend/tsconfig.json @@ -6,7 +6,7 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "Bundler", diff --git a/studio-ui/src/assets/icons/cylinder.svg b/studio-ui/src/assets/icons/cylinder.svg new file mode 100644 index 0000000..c411bd6 --- /dev/null +++ b/studio-ui/src/assets/icons/cylinder.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/sphere.svg b/studio-ui/src/assets/icons/sphere.svg new file mode 100644 index 0000000..7c948a1 --- /dev/null +++ b/studio-ui/src/assets/icons/sphere.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio-ui/src/assets/icons/torus.svg b/studio-ui/src/assets/icons/torus.svg new file mode 100644 index 0000000..7cd4b8b --- /dev/null +++ b/studio-ui/src/assets/icons/torus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio-ui/src/index.ts b/studio-ui/src/index.ts index fe3b7a5..3a2c7e2 100644 --- a/studio-ui/src/index.ts +++ b/studio-ui/src/index.ts @@ -1,5 +1,7 @@ export * from "./lib/button.element"; export * from "./lib/icon.element"; +export * from "./lib/menu.element"; +export * from "./lib/overlay-provider.element"; export * from "./lib/progress.element"; export * from "./lib/toolbar.element"; export * from "./lib/tree.element"; diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index 73d6f5b..2a532e0 100644 --- a/studio-ui/src/lib/button.element.ts +++ b/studio-ui/src/lib/button.element.ts @@ -10,10 +10,12 @@ import "./icon.element"; export class ButtonElement extends LitElement { static override styles = unsafeCSS(styles); + @property({ reflect: true }) + override role = "button"; + @property() icon?: string; @property({ type: Boolean }) disabled = false; - override role = "button"; override tabIndex = 0; protected override willUpdate(changes: PropertyValues): void { @@ -35,9 +37,13 @@ export class ButtonElement extends LitElement { } protected override render = () => html` - ${this.icon - ? html`` - : nothing} + ${this.icon ? html` + + ` : nothing} ` } diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 7e6b556..1f4f345 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -31,6 +31,14 @@ import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark import fasMinus from "@fortawesome/fontawesome-pro/svgs/solid/minus.svg?raw"; import fasListTree from "@fortawesome/fontawesome-pro/svgs/solid/list-tree.svg?raw"; import fasBars from "@fortawesome/fontawesome-pro/svgs/solid/bars.svg?raw"; +import fasPlusLarge from "@fortawesome/fontawesome-pro/svgs/solid/plus-large.svg?raw"; +import farFileImport from "@fortawesome/fontawesome-pro/svgs/regular/file-import.svg?raw"; +import farPhotoFilmMusic from "@fortawesome/fontawesome-pro/svgs/regular/photo-film-music.svg?raw"; +import fasChevronDown from "@fortawesome/fontawesome-pro/svgs/solid/chevron-down.svg?raw"; + +import customCylinder from "../assets/icons/cylinder.svg?raw"; +import customSphere from "../assets/icons/sphere.svg?raw"; +import customTorus from "../assets/icons/torus.svg?raw"; import styles from "./icon.element.scss?inline"; @@ -61,13 +69,16 @@ function renderSvg(iconId: string) { "bone": () => farBone, "bwd-fast": () => fasBwdFast, "bwd-step": () => fasBwdStep, + "caret-right": () => fasCaretRight, "check": () => fasCheck, + "chevron-down": () => fasChevronDown, "circle-check": () => fasCircleCheck, "circle-question": () => fasCircleQuestion, "circle-xmark": () => fasCircleXmark, - "caret-right": () => fasCaretRight, "crosshairs": () => farCrosshairs, "cube": () => farCube, + "cylinder": () => customCylinder, + "file-import": () => farFileImport, "floppy-disk": () => fasFloppyDisk, "fwd-fast": () => fasFwdFast, "fwd-step": () => fasFwdStep, @@ -76,11 +87,15 @@ function renderSvg(iconId: string) { "list-tree": () => fasListTree, "minus": () => fasMinus, "globe": () => farGlobe, + "photo-film-music": () => farPhotoFilmMusic, "play": () => fasPlay, + "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "rotate": () => farRotate, "scale": () => farScale, "skeleton": () => farSkeleton, + "sphere": () => customSphere, + "torus": () => customTorus, "up-down-left-right": () => farUpDownLeftRight, "video": () => farVideo, "xmark": () => fasXmark, @@ -89,7 +104,7 @@ function renderSvg(iconId: string) { if (!svgSrc) return nothing; - const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/)!; + const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/s)!; const attrs = Object.fromEntries( Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g)) .map(([, attr, value]) => [attr, value]) diff --git a/studio-ui/src/lib/menu.element.scss b/studio-ui/src/lib/menu.element.scss new file mode 100644 index 0000000..f1cb459 --- /dev/null +++ b/studio-ui/src/lib/menu.element.scss @@ -0,0 +1,35 @@ +:host { + display: flex; + flex-direction: column; + border-radius: 0.5rem; + padding: 0.5rem 0; + min-width: 220px; + + background: #0006; + backdrop-filter: blur(16px); + overflow: hidden; +} + +::slotted(sts-button) { + background: unset; + backdrop-filter: unset; + border-radius: unset; +} + +::slotted(sts-button:hover) { + background: #0004; + + // FIXME: https://github.com/w3c/csswg-drafts/issues/3896 + &::part(icon) { + opacity: 0.75; + } +} + +::slotted(hr) { + display: block; + width: 100%; + border: none; + height: 1px; + margin: 0.5rem 0; + background: #FFF1; +} diff --git a/studio-ui/src/lib/menu.element.ts b/studio-ui/src/lib/menu.element.ts new file mode 100644 index 0000000..db3c497 --- /dev/null +++ b/studio-ui/src/lib/menu.element.ts @@ -0,0 +1,168 @@ +import { inject, on } from "@storyteller/framework"; +import { LitElement, PropertyValues, css, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { v4 as uuid } from "uuid"; + +import panelStyles from "./menu.element.scss?inline"; + +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "./overlay-provider.element"; + +@customElement("sts-menu") +export class MenuElement extends LitElement { + static override styles = css` + :host { + visibility: hidden; + } + `; + + @property({ attribute: false }) + trigger = createRef(); + + /** Close the menu when one of its menuitems is pressed */ + @property({ type: Boolean }) + autoClose = false; + + @inject(OVERLAY_PROVIDER) + overlay!: OverlayProvider; + + #menuId = uuid(); + #slotRef = createRef(); + #panelRef = createRef(); + #handle?: WeakRef; + + show(): void { + const trigger = this.trigger.value; + if (!trigger) return; + + trigger.classList.toggle("active", true); + trigger.ariaExpanded = "true"; + + const rect = trigger.getBoundingClientRect(); + + this.#handle = this.overlay.attach(this, html` + ${ + // Move the nodes assigned to the 's slot into the + // 's slot + this.#slotRef.value?.assignedNodes() + } + `); + } + + hide(): void { + const trigger = this.trigger.value; + if (trigger) { + trigger.classList.toggle("active", false); + trigger.ariaExpanded = "false"; + } + + if (this.#panelRef.value) { + // Move the nodes assigned to the 's slot back into the + // 's slot + for (let node of this.#panelRef.value.getSlot()!.assignedNodes()) + this.appendChild(node); + } + + if (this.#handle) + this.overlay.detach(this.#handle); + + this.#handle = undefined; + } + + protected override update(changes: PropertyValues): void { + super.update(changes); + + if (this.#handle) + this.overlay.reRender(this.#handle); + } + + protected override updated(changes: PropertyValues): void { + if (changes.has("trigger")) { + const prevTrigger = changes.get("trigger"); + if (prevTrigger?.value) { + prevTrigger.value.removeEventListener("click", this.#onTriggerClick); + } + + const trigger = this.trigger.value; + if (!trigger) return; + + trigger.setAttribute("aria-owns", this.#menuId); + trigger.ariaHasPopup = "true"; + trigger.ariaExpanded = "false"; + + trigger.addEventListener("click", this.#onTriggerClick); + } + + super.updated(changes); + } + + override disconnectedCallback(): void { + this.trigger.value?.removeEventListener("click", this.#onTriggerClick); + super.disconnectedCallback(); + } + + #onTriggerClick = (event: Event): void => { + event.stopPropagation(); + this.show(); + } + + protected override render = () => html` + + ` +} + +// TODO: ARIA keyboard navigation: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ +@customElement("sts-menu-panel") +export class MenuPanelElement extends LitElement { + static override styles = unsafeCSS(panelStyles); + + @property({ reflect: true }) + override role = "menu"; + + @property({ type: Boolean }) + autoClose = false; + + #slotRef = createRef(); + getSlot(): HTMLSlotElement | undefined { return this.#slotRef.value; } + + @on("click") + onClick(event: PointerEvent): void { + event.stopPropagation(); + + if ( + this.autoClose + && event.target + && event.target instanceof HTMLElement + && event.target.role === "menuitem" + ) { + this.dispatchEvent(new CustomEvent("hide")); + } + } + + @on("window:click") + onWindowClick(event: PointerEvent): void { + if (!event.composedPath().includes(this)) + this.dispatchEvent(new CustomEvent("hide")); + } + + protected override render = () => html` + + ` +} diff --git a/studio-ui/src/lib/overlay-provider.element.ts b/studio-ui/src/lib/overlay-provider.element.ts new file mode 100644 index 0000000..774de97 --- /dev/null +++ b/studio-ui/src/lib/overlay-provider.element.ts @@ -0,0 +1,127 @@ +import { UniqueToken, provide } from "@storyteller/framework"; +import { LitElement, TemplateResult, html, render } from "lit"; +import { customElement } from "lit/decorators.js"; +import { Ref, createRef, ref } from "lit/directives/ref.js"; +import { StyleInfo, styleMap } from "lit/directives/style-map.js"; + +export const OVERLAY_PROVIDER = UniqueToken.create(); + +// TODO: Document this +export interface OverlayProvider { + attach( + host: LitElement, + template: TemplateResult, + options?: AttachOptions, + ): WeakRef; + detach(handle: WeakRef): void; + reRender(handle: WeakRef): void; +} + +export interface AttachOptions { + containerStyles?: Readonly; +} + +// TODO: Document this +export interface OverlayHandle { + readonly id: number; +} + +class OverlayHandleInternal implements OverlayHandle { + #id: number; + get id() { return this.#id; } + + constructor (id: number) { + this.#id = id; + } + _updateId(id: number): void { + this.#id = id; + } +} + +@customElement("sts-overlay-provider") +@provide(OVERLAY_PROVIDER) +export class OverlayProviderElement + extends LitElement + implements OverlayProvider +{ + #handles: OverlayHandleInternal[] = []; + #containerRefs: Ref[] = []; + #containerTemplates: TemplateResult[] = []; + #templates: [LitElement, TemplateResult][] = []; + + attach( + host: LitElement, + template: TemplateResult, + options: AttachOptions = {}, + ): WeakRef { + const index = this.#templates.length; + const handle = new OverlayHandleInternal(index); + + const containerStyles = options.containerStyles ?? {}; + + this.#handles.push(handle); + this.#containerRefs.push(createRef()); + this.#containerTemplates.push(html` +
+ `); + this.#templates.push([host, template]); + + this.#updateLightDom(); + + return new WeakRef(handle); + } + + detach(handle: WeakRef): void { + const index = handle.deref()?.id; + if (index == null) return; + + this.#handles.splice(index, 1); + this.#containerRefs.splice(index, 1); + this.#containerTemplates.splice(index, 1); + this.#templates.splice(index, 1); + + for (let i = index; i < this.#handles.length; ++i) + this.#handles[i]._updateId(i); + + this.#updateLightDom(); + } + + reRender(handle: WeakRef): void { + const index = handle.deref()?.id; + if (index == null) return; + + const container = this.#containerRefs[index]?.value; + if (!container) return; + + const tuple = this.#templates[index]; + if (!tuple) return; + + const [host, template] = tuple; + render(template, container, { host }); + } + + #updateLightDom(): void { + render(this.#containerTemplates, this, { host: this }); + + for (let i = 0; i < this.#handles.length; ++i) { + const container = this.#containerRefs[i].value; + if (!container) throw new Error("Attempted to retrieve an invalid container ref!"); + + const [host, template] = this.#templates[i]; + render(template, container, { host }); + } + } + + protected override render = () => html` + + + `; +} diff --git a/studio-web/src/index.ts b/studio-web/src/index.ts index f7b0abf..99ace80 100644 --- a/studio-web/src/index.ts +++ b/studio-web/src/index.ts @@ -1,3 +1,4 @@ +export * from "./lib/add-to-scene-menu.element"; export * from "./lib/canvas-provider.element"; export * from "./lib/iframe-messaging-bridge.element"; export * from "./lib/inspector/entity-inspector.element"; @@ -5,6 +6,7 @@ export * from "./lib/inspector/inspector.types"; export * from "./lib/loading.element"; export * from "./lib/scene-data-provider.element"; export * from "./lib/scene-hierarchy.element"; +export * from "./lib/storyteller-api"; export * from "./lib/studio.element"; export * from "./lib/transform-toolbar.element"; export * from "./lib/url-params"; diff --git a/studio-web/src/lib/add-to-scene-menu.element.ts b/studio-web/src/lib/add-to-scene-menu.element.ts new file mode 100644 index 0000000..54684ad --- /dev/null +++ b/studio-web/src/lib/add-to-scene-menu.element.ts @@ -0,0 +1,71 @@ +import * as studio from "@storyteller/studio"; +import { BlockingCommand } from "@storyteller/studio"; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { createRef } from "lit/directives/ref.js" + +import "@storyteller/studio-ui/button"; +import "@storyteller/studio-ui/menu"; + +@customElement("sts-add-to-scene-menu") +export class AddToSceneMenuElement extends LitElement { + @property({ attribute: false }) + trigger = createRef(); + + protected override render = () => html` + + { + studio.dispatchBlockingCommand(BlockingCommand.Cube); + }} + > + Cube + + { + studio.dispatchBlockingCommand(BlockingCommand.Sphere); + }} + > + Sphere + + { + studio.dispatchBlockingCommand(BlockingCommand.Torus); + }} + > + Torus + + { + studio.dispatchBlockingCommand(BlockingCommand.Cylinder); + }} + > + Cylinder + +
+ + Import asset... + + { + this.dispatchEvent(new CustomEvent("open-media-library")); + }} + > + Media library... + +
+ `; +} diff --git a/studio-web/src/lib/storyteller-api.ts b/studio-web/src/lib/storyteller-api.ts new file mode 100644 index 0000000..27e042d --- /dev/null +++ b/studio-web/src/lib/storyteller-api.ts @@ -0,0 +1,408 @@ +import { match } from "@storyteller/utility"; +import axios, { AxiosResponse } from "axios"; +import { v4 as uuid } from "uuid"; + +export class StorytellerApi { + #api = axios.create({ + baseURL: "https://api.fakeyou.com", + withCredentials: true, + }); + + async createScene(file: File): Promise { + const response = await this.#api.postForm( + "v1/engine/create_scene", + { + uuid_idempotency_token: uuid(), + file, + }, { + headers: { Accept: "text/plain" }, + }, + ); + this.#validateResponse(response); + + return response.data.media_file_token; + } + + async session(): Promise { + const response = await this.#api.get("v1/session"); + this.#validateResponse(response); + + return response.data; + } + + async listMediaFiles(options: ListMediaFilesOptions = {}): Promise { + const params = {} as any; + + if (options.filterMediaType) + params.filter_media_type = options.filterMediaType.join(","); + if (options.sortAscending != null) + params.soer_ascending = options.sortAscending; + if (options.pageSize != null) + params.page_size = options.pageSize; + if (options.pageIndex != null) + params.page_index = options.pageIndex; + + const endpoint = options.username + ? `v1/media_files/list/user/${options.username}` + : "v1/media_files/list"; + + const response = await this.#api.get(endpoint, { params }); + this.#validateResponse(response); + + return response.data; + } + + async uploadMediaFile(file: File, subtype: MediaFileSubtype): Promise { + const [, _basename, extension] = file.name.match(/(.+?)\.(.+)/) ?? []; + const mimeType = match (extension, { + "fbx": () => "application/fbx", + "gltf": () => "model/gltf+json", + "glb": () => "model/gltf-binary", + "scn.ron": () => "text/ron", + _: () => "application/octet-stream", + }); + + const response = await this.#api.postForm( + "v1/media_files/upload/engine_asset", + { + uuid_idempotency_token: uuid(), + file, + media_file_subtype: subtype, + }, + { + headers: { Accept: mimeType }, + }, + ); + this.#validateResponse(response); + + return response.data.media_file_token; + } + + async mediaFile(token: MediaFileToken): Promise { + const response = await this.#api.get(`v1/media_files/file/${token}`); + this.#validateResponse(response); + + return response.data.media_file; + } + + #validateResponse(response: R): asserts response is OkResponse { + if (response.status !== 200) + throw new Error(`API responded with status ${response.status}: "${response.statusText}"`); + + if ( + response.data + && "success" in response.data + && response.data.success === false + ) { + throw new ApiError(response); + } + } +} + +export class ApiError extends Error { + detail: T; + + constructor (detail: T) { + super("API Error"); + this.detail = detail; + } +} + +type OkResponse = T & { status: 200 } + +export interface ListMediaFilesOptions { + username?: string; + filterMediaType?: MediaFileType[]; + pageSize?: number; + pageIndex?: number; + sortAscending?: boolean; +} + +export interface ListMediaFilesResult { + pagination: PaginationPage; + results: MediaFileForUserListItem[]; + success: boolean; +} + +export interface MediaUploadResponse { + success: boolean; + media_file_token: MediaFileToken; +} + +/** + * Pagination by page id. + * + * This type of pagination is by "page id" and "page count". This should never + * be used to walk the entire public database as competitors and investors can + * use it to glean information about the scale of our service. This is best used + * for user profiles and scoped down results. + */ +export interface PaginationPage { + current: number; + total_page_count: number; +} + +export interface MediaFileResult { + success: boolean; + media_file: MediaFile; +} + +export interface MediaFile { + token: MediaFileToken; + media_type: MediaFileType; + maybe_media_subtype?: MediaFileSubtype; + maybe_batch_token?: unknown; + public_bucket_path: string; + maybe_model_weight_info?: unknown; + maybe_creator_user?: UserDetailsLight; + creator_set_visibility: Visibility; + maybe_text_transcript?: string; + maybe_prompt_token?: unknown; + maybe_original_filename?: string; + is_emulated_media_file: boolean; + stats: Stats; + /** Date-time string */ + created_at: string; + /** Date-time string */ + updated_at: string; +} + +export interface MediaFileListItem { + /** Date-time string */ + created_at: string; + creator_set_visibility: Visibility; + maybe_creator?: UserDetailsLight; + /** @deprecated */ + maybe_origin_model_token?: string; + maybe_origin_model_type?: MediaFileOriginModelType; + /** Text transcripts for TTS, etc. */ + maybe_text_transcript?: string; + media_type: MediaFileType; + maybe_media_subtype?: MediaFileSubtype; + origin: MediaFileOriginDetails; + origin_category: MediaFileOriginCategory; + origin_product_category: MediaFileOriginProductCategory; + /** URL to the media file. */ + public_bucket_path: string; + /** The primary key for Media Files */ + token: MediaFileToken; + /** Date-time string */ + updated_at: string; +} + +export interface Stats { + positive_rating_count: number; + bookmark_count: number; +} + +export type MediaFileForUserListItem = Omit; + +export enum Visibility { + Public = "public", + Hidden = "hidden", + Private = "private", +} + +/** + * Used in the `media_files` table in a `VARCHAR` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginModelType { + RvcV2 = "rvc_v2", + SadTalker = "sad_talker", + SoVitsSvc = "so_vits_svc", + Tacotron2 = "tacotron2", + VallEX = "vall_e_x", + Rerender = "rerender", + MocapNet = "mocap_net", + ComfyUi = "comfy_ui", + StyleTts2 = "styletts2", + StableDiffusion_1_5 = "stable_diffusion_1_5", +} + +/** + * Used in the `media_files` table in a `VARCHAR(16)` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileType { + Audio = "audio", + Image = "image", + Video = "video", + Mocap = "mocap", + Bvh = "bvh", + Fbx = "fbx", + Glb = "glb", + Gltf = "gltf", + ScnRon = "scene_ron", +} + +export enum MediaFileSubtype { + /** + * Animation file from Mixamo. + * Primarily used for FBX and GLB. + */ + Mixamo = "mixamo", + /** + * Animation file from MocapNet. + * Primarily used for BVH. + */ + MocapNet = "mocap_net", + /** + * Generic animation case. + * Used for BVH files, but can also pertain to animation-only files of other + * types. + */ + AnimationOnly = "animation_only", + /** + * @deprecated Use `SceneImport` instead. + */ + Scene = "scene", + /** + * Generic 3D scene file. + * Can pertain to glTF, glB, FBX, etc. + */ + SceneImport = "scene_import", + /** + * Native Storyteller scene format. + * Typically stored in a `.scn.ron` file. + */ + StorytellerScene = "storyteller_scene", +} + +/** Fields useful for enriching media file listings */ +export interface MediaFileOriginDetails { + maybe_model?: Model; + origin_category: MediaFileOriginCategory; + product_category: MediaFileOriginProductCategory; +} + +export interface Model { + model_type: MediaFileOriginModelType; + /** + * The model title (typically only populated for model_weights models, not + * legacy tables such as tts_models.) + */ + title?: string; + /** The primary key for the "model_weights" table. */ + token?: string; +} + +/** + * Used in the `media_files` table in a `VARCHAR` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginCategory { + Inference = "inference", + Processed = "processed", + Upload = "upload", + DeviceApi = "device_api", + StoryEngine = "story_engine", +} + +/** + * Used in the `media_files` table in `VARCHAR(16)` field + * `origin_product_category`. + * + * This value indicates what product originally created the media file. (Not the + * ML model or user upload process.) This will let us scope media files to the + * product that generated them and filter them out of unrelated products if + * necessary (eg. a user probably doesn't want "Voice Designer" dataset samples + * in a video generation flow.) + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginProductCategory { + Unknown = "unknown", + FaceAnimator = "face_animator", + Tts = "tts", + VoiceConversion = "voice_conversion", + ZsVoice = "zs_voice", + VideoFilter = "video_filter", + Mocap = "mocap", + ImageGen = "image_gen", + Workflow = "workflow", +} + +export type MediaFileToken = string; + +export interface Session { + logged_in: boolean; + success: boolean; + user?: { + can_access_studio: boolean; + can_approve_w21_templates: boolean; + can_ban_users: boolean; + can_delete_other_users_tts_models: boolean; + can_delete_other_users_tts_results: boolean; + can_delete_other_users_w2l_results: boolean; + can_delete_other_users_w2l_templates: boolean; + can_delete_own_account: boolean; + can_delete_own_tts_models: boolean; + can_delete_own_tts_results: boolean; + can_delete_own_w2l_results: boolean; + can_delete_own_w2l_templates: boolean; + can_delete_users: boolean; + can_edit_other_users_profiles: boolean; + can_edit_other_users_tts_models: boolean; + can_edit_other_users_w2l_templates: boolean; + can_upload_tts_models: boolean; + can_upload_w2l_templates: boolean; + can_use_tts: boolean; + can_use_w2l: boolean; + core_info: UserDetailsLight; + display_name: string; + email_gravatar_hash: string; + fakeyou_plan: FakeYouPlan; + maybe_feature_flags: unknown; + storyteller_stream_plan: StorytellerStreamPlan; + user_token: string; + username: string; + }; +} + +/** + * Everything we need to refer to a user on the public web interface. + */ +export interface UserDetailsLight { + default_avatar: DefaultAvatarInfo; + /** + * As of 2023-08-23, this is the username with capitalization (In the future, + * a display name can be customized by the user.) + */ + display_name: string; + /** + * Email hash for Gravatar Always set for now since login is email/username + + * password. In the future this will need to become an optional OR be filled + * with a bogus hash. + */ + gravatar_hash: string; + /** The primary key for users. */ + user_token: string; + /** + * The unique username someone logs in with. As of 2023-08-23, this is always + * lowercase. + */ + username: string; +} + +export interface DefaultAvatarInfo { + color_index: number; + image_index: number; +} + +export enum FakeYouPlan { + Free = "free", + Basic = "basic", + Standard = "standard", + Pro = "pro", +} + +export enum StorytellerStreamPlan { + Free = "free", + Basic = "basic", + Standard = "standard", + Pro = "pro", +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e4c0493..53ebcae 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,7 +10,7 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": ["es2020", "dom"], + "lib": ["es2021", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", @@ -20,11 +20,14 @@ "@storyteller/studio-ui": ["studio-ui/src/index.ts"], "@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.ts"], "@storyteller/studio-ui/icon": ["studio-ui/src/lib/icon.element.ts"], + "@storyteller/studio-ui/menu": ["studio-ui/src/lib/menu.element.ts"], + "@storyteller/studio-ui/overlay-provider": ["studio-ui/src/lib/overlay-provider.element.ts"], "@storyteller/studio-ui/progress": ["studio-ui/src/lib/progress.element.ts"], "@storyteller/studio-ui/toolbar": ["studio-ui/src/lib/toolbar.element.ts"], "@storyteller/studio-ui/tree": ["studio-ui/src/lib/tree.element.ts"], "@storyteller/studio-ui/forms/text-field": ["studio-ui/src/lib/forms/text-field.element.ts"], "@storyteller/studio-web": ["studio-web/src/index.ts"], + "@storyteller/studio-web/add-to-scene-menu": ["studio-web/src/lib/add-to-scene-menu.element.ts"], "@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"], "@storyteller/studio-web/entity-inspector": ["studio-web/src/lib/inspector/entity-inspector.element.ts"], "@storyteller/studio-web/iframe-messaging-bridge": ["studio-web/src/lib/iframe-messaging-bridge.element.ts"], diff --git a/utility/src/index.ts b/utility/src/index.ts index 1c552cf..e35da3a 100644 --- a/utility/src/index.ts +++ b/utility/src/index.ts @@ -1,3 +1,4 @@ +export * from "./lib/assert"; export * from "./lib/exists"; export * from "./lib/match"; export * from "./lib/types"; diff --git a/utility/src/lib/assert.ts b/utility/src/lib/assert.ts new file mode 100644 index 0000000..1da67e9 --- /dev/null +++ b/utility/src/lib/assert.ts @@ -0,0 +1,8 @@ +export function assert(expression: unknown, message?: string): asserts expression { + if (!expression) { + let err = "Assertion failed"; + if (message) err += `: ${message}`; + + throw new Error(err); + } +}