diff --git a/framework/src/lib/decorators/debounce.ts b/framework/src/lib/decorators/debounce.ts new file mode 100644 index 0000000..79ce479 --- /dev/null +++ b/framework/src/lib/decorators/debounce.ts @@ -0,0 +1,21 @@ +import { type Fn } from "@storyteller/utility"; +import { LitElement } from "lit"; +import { debounce as _debounce, type DebounceSettings } from "lodash"; + +import { getMethodDescriptor } from "./internal"; + +export function debounce(wait: number, options?: DebounceSettings) { + return (proto: T, propName: keyof T, desc: PropertyDescriptor) => { + const method = (desc?.value as Fn) + ?? getMethodDescriptor(proto, propName)?.value + ?? (() => {}); + + const updated: PropertyDescriptor = { + value: _debounce(method, wait, options), + } + + Object.defineProperty(proto, propName, updated); + + return updated; + } +} diff --git a/framework/src/lib/decorators/index.ts b/framework/src/lib/decorators/index.ts index 66c74fd..1f90baa 100644 --- a/framework/src/lib/decorators/index.ts +++ b/framework/src/lib/decorators/index.ts @@ -1 +1,2 @@ +export * from "./debounce"; export * from "./on"; diff --git a/image-generator-demo/src/app/app.element.scss b/image-generator-demo/src/app/app.element.scss index 11870fa..749c3dd 100644 --- a/image-generator-demo/src/app/app.element.scss +++ b/image-generator-demo/src/app/app.element.scss @@ -2,6 +2,7 @@ position: fixed; inset: 0; display: flex; + background: #242433; } // Layout utilities @@ -17,46 +18,137 @@ transform: translateX(-50%); } +.hierarchy-wrapper { + display: flex; + flex: 0 0 auto; + height: 100vh; +} +.hierarchy { + display: block; + width: 100%; + height: 100%; + padding: 1rem { + inline-end: 0.5rem; + } + border: none; + background: transparent; +} +.hierarchy-resize-handle { + width: 0.5rem; + cursor: ew-resize; + background: transparent; + transition: background 66.666ms linear; + + &:hover, &.resizing { + background: #39394c; + } +} + +.split-screen { + display: flex; + flex: 1 0 0; + position: relative; +} + .studio-scene, .image-capture { flex: 0 0 50%; } +.studio-scene { + overflow: hidden; +} +.image-capture { + width: 0; + object-fit: cover; +} -.hierarchy { - width: 256px; - max-height: calc(100vh - 2rem); +.studio-scene.resizing::part(canvas) { + pointer-events: none; } .config { - display: flex; - flex-direction: column; - gap: 0.25em; - - padding: 0.5rem 1rem; + padding: 1rem; background: #0006; backdrop-filter: blur(16px); border-radius: 0.5rem; +} - .checkbox { - display: flex; - align-items: center; - gap: 0.5rem; - height: 1.5rem; +.config-prompt { + display: flex; + gap: 0.5rem; +} - color: #FFFC; - transition: color 0.1s linear; - font: { - family: "Fira Sans", sans-serif; - size: 15px; - weight: 500; - } +.config-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; - &:hover { - color: #FFFF; + color: #FFFC; + transition: color 0.1s linear; + font: { + family: "Fira Sans", sans-serif; + size: 15px; + weight: 500; + } + + &:hover { + color: #FFFF; + } +} + +.config-field { + background: #0004; + border: 1px solid #FFF2; + color: #FFFC; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + font: { + family: "Fira Sans", sans-serif; + size: 1rem; + } + + &::placeholder { + color: #FFF6; + } + + &:focus-visible { + outline: none !important; + border-color: #FFF4; + background: #0006; + color: #FFFF; + } + + &.prompt { + width: 512px; + } + + &.seed { + width: 96px; + appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; } } } +.config-field-group { + position: relative; +} + +.seed-refresh { + position: absolute; + top: 0; + right: 0.5rem; + bottom: 0; + appearance: none; + border: none; + background: transparent; + color: #FFFC; +} + .loading { position: absolute; inset: 0; diff --git a/image-generator-demo/src/app/app.element.ts b/image-generator-demo/src/app/app.element.ts index e8f2dc6..606c239 100644 --- a/image-generator-demo/src/app/app.element.ts +++ b/image-generator-demo/src/app/app.element.ts @@ -1,4 +1,4 @@ -import { on } from "@storyteller/framework"; +import { debounce, drag, on } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { SceneState, @@ -9,7 +9,8 @@ import { import { match } from "@storyteller/utility"; import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { DebounceSettings, debounce } from "lodash"; +import { styleMap } from "lit/directives/style-map.js"; +import { classMap } from "lit/directives/class-map.js"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/scene-hierarchy"; @@ -18,30 +19,19 @@ import "@storyteller/studio-web/transform-toolbar"; import "./app.element.scss" -class Debounce { - static readonly Wait = 1000 / 24; - static readonly Realtime = { - leading: false, - trailing: true, - maxWait: 1000 / 5, - }; - - static readonly Delayed = { - leading: false, - trailing: true, - }; - - static func(config: DebounceSettings) { - return debounce(() => studio.requestScreenshot(), this.Wait, config); - } -} - @customElement("app-root") export class AppElement extends LitElement { - @state() loading = true; @state() imageCaptureUrl?: string; @state() realtimeCapture = true; + @state() hierarchyWidth = 256; + @state() resizing = false; + + @state() loading = true; + + @state() prompt = ""; + @state() seed = randomSeed(); + protected override createRenderRoot = () => this; override disconnectedCallback(): void { @@ -63,10 +53,29 @@ export class AppElement extends LitElement { @on("entity-changes") onStudioChanges(): void { - this.requestScreenshot(); + if (this.realtimeCapture) { + this.requestScreenshot_realtime(); + } else { + this.requestScreenshot_delayed(); + } } - requestScreenshot = Debounce.func(Debounce.Realtime); + @on("viewport-resized") + // This handler requires a longer debounce to avoid crashing the render piptline + @debounce(1000 / 5) + onViewportResized(): void { + studio.requestScreenshot(); + } + + @debounce(1000 / 24, { maxWait: 1000 / 5 }) + requestScreenshot_realtime() { + studio.requestScreenshot(); + } + + @debounce(1000 / 24) + requestScreenshot_delayed() { + studio.requestScreenshot(); + } @on("screenshot-ready") onScreenshotReady({ detail: screenshot }: ScreenshotReadyEvent) { @@ -74,41 +83,109 @@ export class AppElement extends LitElement { const blob = new Blob([screenshot.data.buffer], { type: "image/png" }); this.imageCaptureUrl = URL.createObjectURL(blob); - console.log(`New blob URL: ${this.imageCaptureUrl}`); if (prevUrl != null) URL.revokeObjectURL(prevUrl); } + onResizeStart(): void { + this.resizing = true; + } + + onResizeEnd(): void { + this.resizing = false; + } + + onResize(event: PointerEvent): void { + let updated = this.hierarchyWidth + event.movementX; + this.hierarchyWidth = Math.max(Math.min(updated, 512), 128); + } + toggleRealtimeCapture(event: InputEvent): void { const { checked } = event.target as HTMLInputElement; this.realtimeCapture = checked; + } - const config = checked ? Debounce.Realtime : Debounce.Delayed; - this.requestScreenshot = Debounce.func(config); + refreshSeed(): void { + this.seed = randomSeed(); } protected override render = () => html` - +
+ +
+
- ${this.imageCaptureUrl != null ? html` - Image capture - ` : nothing} +
+ + + + + ${this.imageCaptureUrl != null ? html` + Image capture + ` : html` +
+ `} + +
event.stopPropagation()} + @keyup=${(event: KeyboardEvent) => event.stopPropagation()} + > + +
+ + +
+
+
- -
-