diff --git a/Cargo.lock b/Cargo.lock index a514c1a..6959403 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,7 +290,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" dependencies = [ - "event-listener", + "event-listener 2.5.3", "futures-core", ] @@ -301,21 +301,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] [[package]] name = "async-executor" -version = "1.5.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f2db9467baa66a700abce2a18c5ad793f6f83310aca1284796fc3921d113fd" +checksum = "17ae5ebefcc48e7452b4987947920dac9450be1110cadf34d1b8c116bdbaf97c" dependencies = [ - "async-lock", + "async-lock 3.3.0", "async-task", "concurrent-queue", "fastrand 2.0.1", - "futures-lite 1.13.0", + "futures-lite 2.2.0", "slab", ] @@ -325,7 +325,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" dependencies = [ - "async-lock", + "async-lock 2.8.0", "autocfg", "blocking", "futures-lite 1.13.0", @@ -337,7 +337,18 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" dependencies = [ - "event-listener", + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d034b430882f8381900d3fe6f0aaa3ad94f2cb4ac519b429692a1bc2dda4ae7b" +dependencies = [ + "event-listener 4.0.3", + "event-listener-strategy", + "pin-project-lite", ] [[package]] @@ -498,7 +509,7 @@ checksum = "935984568f75867dd7357133b06f4b1502cd2be55e4642d483ce597e46e63bff" dependencies = [ "async-broadcast", "async-fs", - "async-lock", + "async-lock 2.8.0", "bevy_app", "bevy_asset_macros", "bevy_ecs", @@ -647,7 +658,7 @@ dependencies = [ "bevy_tasks", "bevy_utils", "downcast-rs", - "event-listener", + "event-listener 2.5.3", "fixedbitset", "rustc-hash", "serde", @@ -1357,6 +1368,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "bevy_tweening" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216b3cb82fd0d149c5a9abfb5e382d8a671ff469f8759116e091c710bfa37139" +dependencies = [ + "bevy", + "interpolation 0.2.0", +] + [[package]] name = "bevy_ui" version = "0.12.1" @@ -1617,7 +1638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c36a4d0d48574b3dd360b4b7d95cc651d2b6557b6402848a27d4b228a473e2a" dependencies = [ "async-channel", - "async-lock", + "async-lock 2.8.0", "async-task", "fastrand 2.0.1", "futures-io", @@ -2312,6 +2333,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" +dependencies = [ + "event-listener 4.0.3", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.72.0" @@ -3062,7 +3104,7 @@ dependencies = [ "curl", "curl-sys", "encoding_rs", - "event-listener", + "event-listener 2.5.3", "futures-lite 1.13.0", "http", "log", @@ -3171,9 +3213,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.65" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] @@ -3252,9 +3294,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.148" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libloading" @@ -3846,9 +3888,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" @@ -4394,15 +4436,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.21" +version = "0.38.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" dependencies = [ "bitflags 2.4.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -4495,9 +4537,9 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] @@ -4515,9 +4557,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", @@ -4815,6 +4857,7 @@ dependencies = [ "bevy_mod_picking", "bevy_mod_raycast", "bevy_rapier3d", + "bevy_tweening", "bevy_web_asset", "bitflags 2.4.0", "bytemuck", @@ -4951,18 +4994,18 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ "winapi-util", ] [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ "thiserror-impl", ] @@ -4989,9 +5032,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", @@ -5342,9 +5385,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.88" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -5352,9 +5395,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.88" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" dependencies = [ "bumpalo", "log", @@ -5367,9 +5410,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.37" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" dependencies = [ "cfg-if", "js-sys", @@ -5379,9 +5422,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.88" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5389,9 +5432,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.88" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", @@ -5402,9 +5445,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.88" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "wasm-streams" @@ -5432,9 +5475,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.65" +version = "0.3.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/package-lock.json b/package-lock.json index 024ae5a..27871e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "vite-plugin-dts": "~2.3.0", "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", + "vite-plugin-web-components-hmr": "^0.1.3", "vitest": "~0.32.0", "which": "^4.0.0" } @@ -10409,6 +10410,22 @@ "vite": "^2 || ^3 || ^4" } }, + "node_modules/vite-plugin-web-components-hmr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/vite-plugin-web-components-hmr/-/vite-plugin-web-components-hmr-0.1.3.tgz", + "integrity": "sha512-UF+YYOFyaie6cT7XPatRz2Xo1Fh3TdTwOdCq1Z0EjzMfdAlUiUdF3crkN0DdEhctWBvq18CHxJZUTKw7x1zSRQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-import-assertions": "^7.12.1", + "@babel/plugin-syntax-top-level-await": "^7.12.1", + "picomatch": "^2.2.2" + }, + "peerDependencies": { + "vite": ">=2" + } + }, "node_modules/vitest": { "version": "0.32.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.32.4.tgz", diff --git a/package.json b/package.json index e2e5c35..55de6be 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "vite-plugin-dts": "~2.3.0", "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", + "vite-plugin-web-components-hmr": "^0.1.3", "vitest": "~0.32.0", "which": "^4.0.0" }, diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss index a041364..6a754bf 100644 --- a/studio-frontend/src/app/anim-editor.element.scss +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -1,21 +1,30 @@ :host { - display: flex; + display: grid; border-radius: 8px; background: #0006; backdrop-filter: blur(16px); + grid-template-columns: + [sidebar-start] min-content [sidebar-end + divider-start] 8px [divider-end + timeline-start] 1fr [timeline-end]; + padding: 8px; + row-gap: 8px; } -.sidebar, .timeline { padding: 8px; } -.sidebar { padding-inline-end: 0; } -.timeline { padding-inline-start: 0; } - .sidebar { flex: 0 0 auto; width: 256px; - display: flex; - flex-direction: column; - gap: 8px; + display: grid; overflow: hidden; + grid-column: sidebar; + grid-row: 1 / span var(--row-count); + grid-template-rows: subgrid; +} + +.timeline { + grid-column: timeline; + grid-row: 1 / span var(--row-count); + grid-template-rows: subgrid; } .btn-keyframe { @@ -28,7 +37,11 @@ background: #7A2C34E3; color: #FFF; - &:hover { + &.disabled { + opacity: 0.25; + } + + &:not(.disabled):hover { background: #90363F; } } @@ -58,6 +71,8 @@ } .divider { + grid-column: divider; + grid-row: 1 / span var(--row-count); position: relative; flex: 0 0 8px; margin: { @@ -83,6 +98,25 @@ } } -.timeline { - flex: 1 0 0; -} +.tracklist { + display: grid; + grid-template-columns: subgrid; + grid-row: 2 / span var(--track-count); + + + .track { + &:hover { + background: #0000001F; + } + + &.selected { + background: #7A2C34E3; + color: #FFF; + font-weight: 500; + + &:hover { + background: #90363F !important; + } + } + } +} \ No newline at end of file diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index 4e3cbbd..c27ff19 100644 --- a/studio-frontend/src/app/anim-editor.element.ts +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -1,7 +1,7 @@ import * as studio from "@storyteller/studio"; -import { type CameraKeyframesEvent } from "@storyteller/studio"; -import { bind, drag, on } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit"; +import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe } from "@storyteller/studio"; +import { bind, drag, inject, on, provide } from "@storyteller/framework"; +import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult, PropertyValueMap } from "lit"; import { customElement, state } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; @@ -11,32 +11,170 @@ import "@storyteller/studio-ui/tree"; import "./anim-timeline.element"; import styles from "./anim-editor.element.scss?inline"; +import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "@storyteller/studio-web/scene-data-provider"; +import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree"; + +const TRACK_ICONS : Record = { + camera: "video", + transform: "up-down-left-right", + light: "light" +}; + +function isOutsideEntity( + value?: { track: string } | { outsideEntity: string } +): value is { outsideEntity: string } { + if (value == null) return false; + return "outsideEntity" in value; +} @customElement("sts-anim-editor") -export class AnimEditorElement extends LitElement { +@provide(TREE_SELECTION_PROVIDER) +export class AnimEditorElement extends LitElement +implements TreeSelectionProvider { + @inject(SCENE_DATA_PROVIDER) + sceneData?: SceneDataProvider; + static override styles = unsafeCSS(styles); @state() sidebarWidth = 256; @state() timelineStart = 0; - @state() timelineEnd = 5_000; + @state() timelineEnd = 5; @state() clipStart = 0; - @state() clipEnd = 15_000; + @state() clipEnd = 0; @state() playHead = 0; - @state() keyframes: Record = {}; + @state() playing = false; + + @state() tracks : Record = {}; + + @state() track_ordered : string[] = []; + + @state() keyframes : Record = {}; + + @state() selectedTrack?: { track: string } | { outsideEntity: string }; + + trackCount(): number { + return Object.keys(this.tracks).length; + } + + get selectedId() { + return !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || null; + } protected override update(changes: PropertyValues): void { if (changes.has("playHead")) studio.seekTime(this.playHead); + if (changes.has("selectedTrack")) { + const treeSelectionChanges: PropertyValues = new Map(); + const prevSelectedTrack = changes.get("selectedTrack"); + const prevTreeSelection = !isOutsideEntity(prevSelectedTrack) && prevSelectedTrack?.track || null; + treeSelectionChanges.set("selectedId", prevTreeSelection); + + this.dispatchEvent(new CustomEvent("property-changes", { + detail: treeSelectionChanges, + })); + } + super.update(changes); } - @on("window:camera-keyframes") - onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void { + @on("window:seek-time-changed") + onSeekTimeChanged({ detail: {time, playing} }: SeekTimeChanged ): void { + console.log("Seek Time Changed", time, playing); + this.playHead = time; + this.playing = playing; + + if (this.playHead < this.timelineStart) { + this.timelineStart = this.playHead - (this.timelineEnd - this.timelineStart) * 0.05; + } + if (this.playHead > this.timelineEnd) { + this.timelineEnd = this.playHead + (this.timelineEnd - this.timelineStart) * 0.05; + } + } + + @on("window:update-animation-targets") + onUpdateAnimationTargets({detail: { added, removed}}: UpdateAnimationTargets) : void { + let addedKeys = Object.keys(added); + if (addedKeys.length === 0 && removed.length === 0) { + return; + } + console.log("Animation Targets Changed"); + let tracks = {...this.tracks}; + for (let track_entity of addedKeys) { + let track = added[track_entity]; + tracks[track_entity] = track; + } + + for (let track_entity of removed) { + delete tracks[track_entity]; + } + + this.track_ordered = Object.keys(tracks); + + if (typeof this.selectedTrack === "object") { + if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) { + this.selectedTrack = { outsideEntity: this.selectedTrack.track}; + } else if (isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity in tracks) { + this.selectedTrack = { track: this.selectedTrack.outsideEntity}; + } + } + + this.tracks = tracks; + } + + @on("window:update-keyframes") + onUpdateKeyframes({detail: {added, removed}}: studio.UpdateKeyframes): void { + let addedKeyframes = Object.keys(added); + if (addedKeyframes.length === 0 && removed.length === 0) { + return; + } + console.log("Keyframes Changed"); + let keyframes = {...this.keyframes}; + for (let keyframe of addedKeyframes) { + keyframes[keyframe] = added[keyframe]; + } + + for (let keyframe of removed) { + delete keyframes[keyframe]; + } + this.keyframes = keyframes; + + let min = Infinity + let max = -Infinity; + for (const keyframe of Object.keys(this.keyframes)) { + let time = this.keyframes[keyframe].time; + if (typeof time != "number") { + continue; + } + if (time > max) { + max = time; + } + if (time < min) { + min = time; + } + } + this.clipStart = Math.max(min, 0.); + this.clipEnd = Math.max(max, min); + } + + + @on("window:entity-select") + onEntitySelected({ detail: id }: EntitySelectEvent): void { + if (id) { + if (this.tracks[id]) { + this.selectedTrack = { track: id }; + } else if (!this.keyframes[id]) { + this.selectedTrack = {outsideEntity: id}; + } else { + this.selectedTrack = undefined; + } + } else { + this.selectedTrack = undefined; + } } onSidebarResize(event: PointerEvent): void { @@ -44,47 +182,140 @@ export class AnimEditorElement extends LitElement { } addKeyframe(): void { - studio.addCameraKeyframe(this.playHead); + if (typeof this.selectedTrack === "object" && "track" in this.selectedTrack) { + studio.recordKeyframe([this.selectedTrack.track]); + } } jumpToStart(): void { - this.playHead = this.clipStart; + if (Math.abs(this.playHead - this.clipStart) < 0.0001) { + this.playHead = 0; + } else { + this.playHead = this.clipStart; + } } prevKeyframe(): void { let pos = this.playHead; - const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => b - a); - for (let keyframe of keyframes) { - pos = keyframe; - if (keyframe < this.playHead) - break; + let closest_keyframe = -1 * Infinity; + + for (const keyframe of Object.keys(this.keyframes)) { + const time = this.keyframes[keyframe]?.time; + if (!time || typeof time != "number") { + continue; + } + if (time < pos && time > closest_keyframe ) { + closest_keyframe = time; + } } - this.playHead = pos; + if (closest_keyframe < 0) { + closest_keyframe = 0; + } + + this.playHead = closest_keyframe; } nextKeyframe(): void { + let pos = this.playHead; - const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => a - b); - for (let keyframe of keyframes) { - pos = keyframe; - if (keyframe > this.playHead) - break; + let closest_keyframe = Infinity; + + for (const keyframe of Object.keys(this.keyframes)) { + const time = this.keyframes[keyframe]?.time; + if (!time || typeof time != "number") { + continue; + } + if (time > pos && time < closest_keyframe ) { + closest_keyframe = time; + } } - this.playHead = pos; + if (closest_keyframe > this.clipEnd) { + return; + } + + this.playHead = closest_keyframe; } jumpToEnd(): void { this.playHead = this.clipEnd; } - protected override render = (): TemplateResult => html` + addTarget(): void { + if (isOutsideEntity(this.selectedTrack)) { + studio.addEntityTrack(this.selectedTrack.outsideEntity); + } + } + + togglePlaying(): void { + console.log("toggling play"); + if (this.playing) { + studio.pause(); + } else { + studio.play(); + } + } + + protected override render(): TemplateResult { + + const track_order_hashed : Record = {}; + + for (const [index, track] of this.track_ordered.entries()) { + track_order_hashed[track] = index; + } + + const selected = !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || false; + const canAddSelected = isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity || false; + + let length = this.timelineEnd - this.timelineStart; + let clip_start = (this.clipStart - this.timelineStart) / length; + let clip_end = (this.clipEnd - this.timelineStart) / length; + + + let timeline_css_variables = { + '--track-count': `${this.trackCount()}`, + '--row-count': `calc(var(--track-count) + ${canAddSelected ? 3 : 2})`, + '--clip-end': `${clip_end * 100}%`, + '--clip-start': `${clip_start * 100}%` + }; + + const tracklist = this.track_ordered.map((entity) => { + let track = this.tracks[entity]; + let name = track.name ?? track.target_type; + let icon = this.sceneData?.entityMap.get(entity)?.icon ?? "minus"; + return html` + { + studio.select(entity); + }} + .id=${entity} + > + `; + }); + + if (canAddSelected) { + tracklist.push(html` + + `); + } + + const none_selected = !selected; + + return html` -
- `; -} + ` + }; +}; + diff --git a/studio-frontend/src/app/anim-timeline.element.scss b/studio-frontend/src/app/anim-timeline.element.scss index fa1b681..c866c93 100644 --- a/studio-frontend/src/app/anim-timeline.element.scss +++ b/studio-frontend/src/app/anim-timeline.element.scss @@ -1,6 +1,9 @@ :host { position: relative; overflow: hidden; + display: grid; + grid-template-rows: subgrid; + grid-row: 1 / span var(--row-count); } *:focus-visible { @@ -8,19 +11,26 @@ } .wrapper { - position: absolute; + position: relative; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: subgrid; inset: 0; + grid-row: 1 / span var(--row-count); + + background: linear-gradient(90deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.2) calc(var(--clip-start) - 1px), rgba(255,255,255,0.1) var(--clip-start), rgba(255,255,255,0.1) var(--clip-end), rgba(0,0,0,0.2) calc(var(--clip-end) + 1px)); &.panning { cursor: grabbing; } -} -.clip-region { - position: absolute; - top: 0; - bottom: 0; - background: #0004; + .zoom-controls { + grid-row: var(--row-count); + background: rgba(0,0,0,1); + height: 10px; + width: 100%; + z-index: 1; + } } .hash { @@ -37,7 +47,7 @@ .timestamp { position: absolute; - top: 8px; + grid-row: 1; color: #FFF6; font: { family: "Fira Sans", sans-serif; @@ -100,8 +110,7 @@ .keyframe { position: absolute; - top: 50%; - transform: translateX(-4.5px) translateY(-4.5px); + transform: translateX(-4.5px); &::before { content: ""; diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts index 537c56d..21db416 100644 --- a/studio-frontend/src/app/anim-timeline.element.ts +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -22,7 +22,7 @@ import styles from "./anim-timeline.element.scss?inline"; // which would look something like: // ``` // @memo(["rangeStart", "rangeEnd", "_renderWidth"]) -// msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); +// secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); // ``` // But that feels a little obtuse and overengineered. In this particular // case we're probably only saving on the order of nanoseconds, so I'm @@ -54,39 +54,36 @@ class Computed extends Number { export class AnimTimelineElement extends LitElement { static override styles = unsafeCSS(styles); - /** The time (in ms) at the left edge of the timeline view */ + /** The time (in seconds) at the left edge of the timeline view */ @property({ type: Number }) rangeStart = 0; - /** The time (in ms) at the right edge of the timeline view */ - @property({ type: Number }) rangeEnd = 5_000; - - /** The time (in ms) at the beginning of the active clip range */ + /** The time (in seconds) at the right edge of the timeline view */ + @property({ type: Number }) rangeEnd = 5; + + + /** The time (in seconds) at the start of the clip */ @property({ type: Number }) clipStart = 0; - /** The time (in ms) at the end of the active clip range */ - @property({ type: Number }) clipEnd = 15_000; + /** The time (in seconds) at the end of the clip */ + @property({ type: Number }) clipEnd = 5; - /** The time (in ms) of the current play-head position */ + /** The time (in seconds) of the current play-head position */ @property({ type: Number }) playHead = 0; - /** Keyframe positions (in ms) */ - @property({ attribute: false }) keyframes: Record = {}; + /** Keyframe positions (in seconds) */ + @property({ attribute: false }) keyframes: Record = {}; + @property({ attribute: false }) track_ordered: Record = {} @state() _selectedKeyframe: string | null = null; /** The width (in px) of the timeline view */ @state() _renderWidth!: number; - /** The offset from 0ms of the left edge of the timeline view, in px */ - @state() _offsetX = 0; - /** The vertical scroll offset (in px) */ - @state() _offsetY = 0; - /** * `true` when the timeline is being mouse-panned. Primarily useful for * setting the cursor style to the grabby hand. */ @state() _panning = false; - /** How many ms are represented by each px of horizontal space. */ - msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); + /** How many seconds are represented by each px of horizontal space. */ + secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); #resizeObserver: ResizeObserver; @@ -107,12 +104,13 @@ export class AnimTimelineElement extends LitElement { } protected override update(changes: PropertyValues): void { + console.log("Changes: ", changes); if ( changes.has("rangeStart") || changes.has("rangeEnd") || changes.has("_renderWidth") ) { - this.msPerPx.invalidate(); + this.secondsPerPx.invalidate(); } super.update(changes); @@ -124,7 +122,7 @@ export class AnimTimelineElement extends LitElement { event.stopImmediatePropagation(); if (event.ctrlKey) { - this.#zoomTimeline(event.offsetX, event.deltaY); + this.#zoomTimeline(event.clientX, event.deltaY); } else if (event.deltaX) { this.#pan(-event.deltaX); } else if (event.shiftKey && event.deltaY) { @@ -140,8 +138,9 @@ export class AnimTimelineElement extends LitElement { } if (Array.from(Object.keys(this.keyframes)).includes(id)) { + let keyframe = this.keyframes[id]; this._selectedKeyframe = id; - this.dispatchEvent(new CustomEvent("playHead-change", { detail: this.keyframes[id]! })); + studio.seekTime(keyframe.time); } else { this._selectedKeyframe = null; } @@ -153,67 +152,63 @@ export class AnimTimelineElement extends LitElement { #zoomTimeline(origin: number, delta: number): void { const currentRange = this.rangeEnd - this.rangeStart; - const deltaRange = delta * 4; + const elementRect = this.getBoundingClientRect(); + const focusPoint = (origin - elementRect.x) / elementRect.width; - // FIXME: When zooming the timeline, the transform origin should be - // centered on the mouse cursor. Currently this does not work as - // intended, always centering on the 00:00 timestamp instead. - const t = ((this._offsetX + origin) * this.msPerPx.get()) / currentRange; - console.log(`t: ${t}`); - console.log(`deltaStart: ${-deltaRange * t}`); - console.log(`deltaEnd: ${deltaRange * (1 - t)}`); - const rangeStart = this.rangeStart - deltaRange * t; - const rangeEnd = this.rangeEnd + deltaRange * (1 - t); + const deltaTimeScale = delta * 0.001 * currentRange; + + const rangeStart = Math.max(this.rangeStart - deltaTimeScale * focusPoint, 0.); + const rangeEnd = Math.max(this.rangeEnd + deltaTimeScale * (1 - focusPoint), rangeStart + 0.01); this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart })); this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd })); } - #onDragStart(): void { + onDragStart(): void { this._panning = true; } - #onDragEnd(): void { + onDragEnd(): void { this._panning = false; } - #onDrag(event: PointerEvent): void { + onDrag(event: PointerEvent): void { this.#pan(event.movementX); } #pan(deltaPx: number): void { - this._offsetX += deltaPx; + const deltaChange = deltaPx * 0.001; + const scaledChange = (this.rangeEnd - this.rangeStart) * deltaChange; - const msPerPx = this.msPerPx.get(); - const rangeStart = this.rangeStart - deltaPx * msPerPx; - const rangeEnd = this.rangeEnd - deltaPx * msPerPx; + let rangeStart = this.rangeStart - scaledChange; + let rangeEnd = Math.max(this.rangeEnd - scaledChange, rangeStart + 0.01); + + if (rangeStart < 0) { + rangeEnd = this.rangeEnd - this.rangeStart; + rangeStart = 0; + } this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart })); this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd })); } - #onPlayheadDrag(event: PointerEvent): void { - const deltaMs = event.movementX * this.msPerPx.get(); - const updated = this.playHead + deltaMs; + onPlayheadDrag(event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; + const updated = seconds; this.dispatchEvent(new CustomEvent("playHead-change", { detail: updated })); } - #onKeyframeDrag(id: string, event: PointerEvent): void { - const deltaMs = event.movementX * this.msPerPx.get(); - const updated = this.keyframes[id] + deltaMs; + onKeyframeDrag(id: string, event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const time = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; - studio.updateKeyframe(id, updated); - - const updatedKeyframes = Object.fromEntries( - Object - .entries(this.keyframes) - .map(entry => entry[0] === id - ? [id, updated] - : entry - ) - ); - this.dispatchEvent(new CustomEvent("keyframes-change", { detail: updatedKeyframes })); + studio.moveKeyframeTime(id, time); } #updateRenderWidth(): void { @@ -221,67 +216,76 @@ export class AnimTimelineElement extends LitElement { } /** - * Format a time value (in ms) for displaying in the timeline view + * Format a time value (in seconds) for displaying in the timeline view */ - #timestamp(ms: number) { - const sign = ms < 0 ? "-" : ""; - ms = Math.abs(ms); + timestamp(seconds: number) { + const sign = seconds < 0 ? "-" : ""; + seconds = Math.abs(seconds); - let s = Math.floor(ms / 1000); - let frac = Math.round(ms % 1000); - if (frac === 1000) { - frac = 0; - s += 1; - } - - let m = 0; - if (s >= 60) { - m = Math.floor(s / 60); - s %= 60; - } + let minutes = Math.floor(seconds / 60); + let secondsInMinute = seconds % 60; + let flooredSeconds = Math.floor(secondsInMinute); + let fraction = Math.floor(100 * (secondsInMinute - flooredSeconds)); return html`${ sign }${ - m.toString(10).padStart(2, "0") + minutes.toString(10).padStart(2, "0") }:${ - s.toString(10).padStart(2, "0") - }`; + flooredSeconds.toString(10).padStart(2, "0") + }.${ + fraction.toString(10).padStart(2, "0") + }`; } - /** - * Convert a time value (in ms) to a px offset from the left edge of the view - */ - #msToPx(ms: number): number { - return this._offsetX + ms / this.msPerPx.get(); - } - - protected override render = () => { - const msPerPx = this.msPerPx.get(); + protected override render() { const range = this.rangeEnd - this.rangeStart; - let subdivsPerSec = 32; - let subdivsCount = (range / 1000) * subdivsPerSec; - let pxPerSubdiv = this._renderWidth / subdivsCount; + let subdivisionScale = 0.5; + let majorSubdivisionScale = 1; - while (pxPerSubdiv < 64) { - subdivsPerSec /= 2; - subdivsCount = (range / 1000) * subdivsPerSec; - pxPerSubdiv = this._renderWidth / subdivsCount; + if (range < 0.5) { + subdivisionScale = 0.05; + majorSubdivisionScale = 0.1; + } else if (range < 2) { + subdivisionScale = 1 / 4; + majorSubdivisionScale = 0.5; + } else if (range > 120) { + subdivisionScale = 30; + majorSubdivisionScale = 60; + } else if (range > 60) { + subdivisionScale = 10; + majorSubdivisionScale = 30; + } else if (range > 10) { + subdivisionScale = 5; + majorSubdivisionScale = 10; + } else if (range > 5) { + subdivisionScale = 1; + majorSubdivisionScale = 5; } - const msPerSubdiv = msPerPx * pxPerSubdiv; - const subdivs = new Array(Math.ceil(subdivsCount) + 2).fill(null); + const scaledStart = this.rangeStart / subdivisionScale; + const scaledEnd = this.rangeEnd / subdivisionScale; - const fracPxOffset = this._offsetX % pxPerSubdiv; + console.log("Scaled Start", scaledStart, "Scaled End", scaledEnd, "Range Start", this.rangeStart, "Range End", this.rangeEnd, "Range", range, "Playhead", this.playHead); + + let subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean}> = []; + let currentOffset = scaledStart; + let currentOffsetSeconds = this.rangeStart; - let startingIdx = -Math.floor(this._offsetX / pxPerSubdiv); - if (fracPxOffset < 0) - startingIdx -= 2; - else - startingIdx -= 1; + while (currentOffset < scaledEnd) { + const currentSeconds = currentOffsetSeconds; + const isMajor = currentOffsetSeconds % majorSubdivisionScale === 0; + subdivisions.push({ seconds: currentSeconds, fraction: (currentSeconds - this.rangeStart) / range, isMajor }); + let nextSubdiv = Math.ceil(currentOffset); + if (Math.abs(nextSubdiv - currentOffset) < 0.1) { + nextSubdiv += 1; + } + currentOffset = nextSubdiv; + currentOffsetSeconds = currentOffset * subdivisionScale; + } + + console.log("Subdivisions", subdivisions.length, subdivisions); return html`
-
- ${subdivs.map((_, idx) => { - const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset; - const ms = (startingIdx + idx) * msPerSubdiv; - - const msFrac = Math.abs(Math.round(ms) % 1000); - const strong = msFrac === 0; + ${subdivisions.map(({ seconds, fraction, isMajor}, idx) => { + const secondsPercentage = fraction * 100; return html`
- ${strong ? html` + ${true ? html` - ${this.#timestamp(ms)} + ${this.timestamp(seconds)} ` : nothing} `; @@ -335,10 +329,10 @@ export class AnimTimelineElement extends LitElement {
@@ -349,22 +343,26 @@ export class AnimTimelineElement extends LitElement { ${repeat( Object.entries(this.keyframes), ([id]) => id, - ([id, keyframe]) => html` + ([id, keyframe]) => { + let row = this.track_ordered[keyframe.target]; + return html`
this.#onKeyframeDrag(id, event), + move: event => this.onKeyframeDrag(id, event), })} @click=${() => this.selectKeyframe(id)} >
- ` + `; + } )}
`; diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 1c574be..e99d07b 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -54,8 +54,8 @@ export class AppElement extends LitElement { @state() inspectorWidth = 320; @state() hierarchyHeight?: number; + #enableAnimTimeline = true; // TODO private api = new StorytellerApi(); - #enableAnimTimeline = false; // TODO #addToSceneButtonRef = createRef(); #mediaLibraryRef = createRef(); @@ -136,7 +136,8 @@ export class AppElement extends LitElement { this.showHelp = !this.showHelp; } - protected override render = () => html` + protected override render() { + return html` @@ -290,4 +291,5 @@ export class AppElement extends LitElement { `; + } } diff --git a/studio-frontend/tsconfig.json b/studio-frontend/tsconfig.json index a8f3853..b5d79d1 100644 --- a/studio-frontend/tsconfig.json +++ b/studio-frontend/tsconfig.json @@ -22,6 +22,7 @@ "noImplicitAny": true, "noImplicitOverride": true, "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, "types": ["vite/client"] }, diff --git a/studio-frontend/vite.config.ts b/studio-frontend/vite.config.ts index 2d5913f..0a03965 100644 --- a/studio-frontend/vite.config.ts +++ b/studio-frontend/vite.config.ts @@ -6,12 +6,14 @@ import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; import wasm from "vite-plugin-wasm"; import topLevelAwait from "vite-plugin-top-level-await"; import mergeAssetsDirs from "../vite-multiple-assets"; +import { hmrPlugin, presets} from "vite-plugin-web-components-hmr"; export default defineConfig({ cacheDir: "../node_modules/.vite/studio-frontend", server:{ port: 4200, host: "localhost", + hmr: true, }, preview:{ port: 4300, @@ -25,5 +27,9 @@ export default defineConfig({ "studio-frontend/public", "dist/studio/public", ]), + hmrPlugin({ + include: ['./src/**/*.ts'], + presets: [presets.lit] + }) ], }); diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 4a503b6..50dddaa 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -12,6 +12,7 @@ import farCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw"; import farGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw"; import farUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw"; import fasPlay from "@fortawesome/fontawesome-pro/svgs/solid/play.svg?raw"; +import fasPause from "@fortawesome/fontawesome-pro/svgs/solid/pause.svg?raw"; import fasFwdStep from "@fortawesome/fontawesome-pro/svgs/solid/forward-step.svg?raw"; import fasFwdFast from "@fortawesome/fontawesome-pro/svgs/solid/forward-fast.svg?raw"; import fasBwdStep from "@fortawesome/fontawesome-pro/svgs/solid/backward-step.svg?raw"; @@ -100,6 +101,7 @@ function renderSvg(iconId: string) { "globe": () => farGlobe, "photo-film-music": () => farPhotoFilmMusic, "play": () => fasPlay, + "pause": () => fasPause, "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "rotate": () => farRotate, diff --git a/studio/Cargo.toml b/studio/Cargo.toml index 4f6b018..2a958e0 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -49,6 +49,7 @@ bytemuck = "1.14.1" space_editor = {git = "https://github.com/rewin123/space_editor", rev = "ce83f86dc902787ab16213f3209276a784654df6"} anyhow.workspace = true serde_repr = "0.1.18" +bevy_tweening = "0.9" [dependencies.reqwest] version = "0.11.24" diff --git a/studio/scripts/build-web.mjs b/studio/scripts/build-web.mjs index 10b0c47..dda5eec 100644 --- a/studio/scripts/build-web.mjs +++ b/studio/scripts/build-web.mjs @@ -2,11 +2,12 @@ import fs from "node:fs"; import url from "node:url"; import path from "node:path"; import { rimraf } from "rimraf"; -import which from "which"; import copyAssets from "./copy-assets.mjs"; import handleError from "./handle-error.mjs"; -import run from "./run.mjs"; +import run, { wasmBindgenCliVersion } from "./run.mjs"; + +const WASM_BINDGEN_CLI_VERSION = "0.2.92"; async function main() { try { @@ -39,11 +40,11 @@ async function main() { stdio, }); - const wasmBindgenInstalled = Boolean(await which("wasm-bindgen", { nothrow: true })); - if (!wasmBindgenInstalled) { + const wbVersion = await wasmBindgenCliVersion(); + if (!wbVersion || !wbVersion.includes(WASM_BINDGEN_CLI_VERSION)) { await run("cargo", [ "install", "-f", "wasm-bindgen-cli", - "--version", "0.2.88", + "--version", WASM_BINDGEN_CLI_VERSION, ], { shell: true, cwd: workspaceRoot, diff --git a/studio/scripts/run.mjs b/studio/scripts/run.mjs index 91ffe08..5f9d021 100644 --- a/studio/scripts/run.mjs +++ b/studio/scripts/run.mjs @@ -1,5 +1,6 @@ import chalk from "chalk"; import cp from "node:child_process"; +import which from "which"; /** * @param {string} command @@ -25,3 +26,35 @@ export default function run(command, args, options) { .on("error", reject); }); } + +/** + * @returns {Promise} + */ +export async function wasmBindgenCliVersion() { + const exePath = await which("wasm-bindgen", { nothrow: true }); + if (!exePath) return null; + + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + const finish = code => { + if (code) reject(new Error(stderr)); + else resolve(stdout); + } + + const proc = cp.spawn(exePath, ["--version"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }) + .on("exit", finish) + .on("close", finish) + .on("error", reject); + + proc.stdout.setEncoding("utf-8"); + proc.stdout.on("data", chunk => stdout += chunk); + + proc.stderr.setEncoding("utf-8"); + proc.stderr.on("data", chunk => stderr += chunk); + }); +} diff --git a/studio/src/anim/camera.rs b/studio/src/anim/camera.rs deleted file mode 100644 index eab3be6..0000000 --- a/studio/src/anim/camera.rs +++ /dev/null @@ -1,208 +0,0 @@ -use bevy::prelude::*; -use itertools::Itertools; -use space_editor::space_prefab::editor_registry::EditorRegistryExt; - -use crate::math::{InvLerp, Lerp}; - -use super::GlobalSeekTime; - -pub struct CameraAnimPlugin; -impl Plugin for CameraAnimPlugin { - fn build(&self, app: &mut App) { - app.register_type::() - .editor_registry::() - .register_type::() - .editor_registry::(); - - app.add_systems(Update, draw_camera_keyframes); - - #[cfg(feature = "wasm")] - app.add_systems( - Update, - ( - wasm::spawn_camera_keyframes, - wasm::notify_keyframe_changes.map(bevy::utils::error), - wasm::process_update_keyframe, - ), - ); - } -} - -#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)] -#[reflect(Component)] -pub struct CameraKeyframe(pub f32); - -#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)] -#[reflect(Component)] -pub struct CameraTargetKeyframe(pub f32); - -fn draw_camera_keyframes( - mut gizmos: Gizmos, - r_seek_time: Res, - q_cam_keyframe_xforms: Query<&GlobalTransform, With>, - q_cam_target_keyframe_xforms: Query<&GlobalTransform, With>, - q_cam_keyframes: Query<(&CameraKeyframe, &GlobalTransform)>, -) { - if q_cam_keyframes.is_empty() { - return; - } - - for world_xform in q_cam_keyframe_xforms - .iter() - .chain(q_cam_target_keyframe_xforms.iter()) - { - let origin = world_xform.translation(); - let x_axis = world_xform.right() * 0.25; - let y_axis = world_xform.up() * 0.25; - let z_axis = world_xform.back() * 0.25; - - gizmos.line(origin, origin + x_axis, Color::RED); - gizmos.line(origin, origin + y_axis, Color::GREEN); - gizmos.line(origin, origin + z_axis, Color::BLUE); - } - - let seek_time = **r_seek_time; - let keyframes_sorted = q_cam_keyframes - .iter() - .sorted_by(|(a, _), (b, _)| a.partial_cmp(&b.0).unwrap()) - .collect_vec(); - - let (timestamp_first, xform_first) = keyframes_sorted.first().unwrap(); - let (timestamp_last, xform_last) = keyframes_sorted.last().unwrap(); - - if seek_time <= timestamp_first.0 { - let (_, rot, pos) = xform_first.to_scale_rotation_translation(); - gizmos.sphere(pos, rot, 0.25, Color::WHITE); - } else if seek_time >= timestamp_last.0 { - let (_, rot, pos) = xform_last.to_scale_rotation_translation(); - gizmos.sphere(pos, rot, 0.25, Color::WHITE); - } else if let Some(((timestamp_a, xform_a), (timestamp_b, xform_b))) = keyframes_sorted - .iter() - .tuple_windows() - .find(|((a, _), (b, _))| a.0 <= seek_time && b.0 >= seek_time) - { - let t = seek_time.inv_lerp(timestamp_a.0, timestamp_b.0); - let (_, rot_a, pos_a) = xform_a.to_scale_rotation_translation(); - let (_, rot_b, pos_b) = xform_b.to_scale_rotation_translation(); - let pos = t.lerp(pos_a, pos_b); - let rot = Quat::slerp(rot_a, rot_b, t); - - gizmos.sphere(pos, rot, 0.25, Color::WHITE); - } -} - -#[cfg(feature = "wasm")] -mod wasm { - use std::sync::Arc; - - use bevy::{prelude::*, window::PrimaryWindow}; - use space_editor::prelude::space_undo::{self, AddedEntity}; - use wasm_bindgen::prelude::*; - - use crate::{ - camera_controller::CameraController, - wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer}, - }; - - use super::{CameraKeyframe, CameraTargetKeyframe}; - - static ADD_KEYFRAME: WasmMessageBuffer = WasmMessageBuffer::new(); - static UPDATE_KEYFRAME: WasmMessageBuffer<(Entity, f32)> = WasmMessageBuffer::new(); - - #[wasm_bindgen(js_name = addCameraKeyframe)] - pub fn add_camera_keyframe(timestamp: f32) { - ADD_KEYFRAME.write_message(timestamp); - } - - pub(super) fn spawn_camera_keyframes( - mut cmd: Commands, - mut ew_changes: EventWriter, - q_camera: Query<(&CameraController, &GlobalTransform)>, - ) { - if let Some(timestamp) = ADD_KEYFRAME.take_message() { - let Ok((cam_controller, world_xform)) = q_camera.get_single() else { - return; - }; - - let entity = cmd - .spawn(( - Name::new("Camera Target Keyframe"), - TransformBundle::from_transform(Transform::from_translation( - cam_controller.focus, - )), - CameraTargetKeyframe(timestamp), - )) - .id(); - ew_changes.send(space_undo::NewChange { - change: Arc::new(AddedEntity { entity }), - }); - - let entity = cmd - .spawn(( - Name::new("Camera Keyframe"), - TransformBundle::from_transform(Transform::from(*world_xform)), - CameraKeyframe(timestamp), - )) - .id(); - ew_changes.send(space_undo::NewChange { - change: Arc::new(AddedEntity { entity }), - }); - } - } - - #[wasm_bindgen(js_name = updateKeyframe)] - pub fn update_keyframe(entity: &str, timestamp: f32) { - let entity = Entity::from_str(entity).unwrap(); - UPDATE_KEYFRAME.write_message((entity, timestamp)); - } - - pub(super) fn process_update_keyframe( - mut q_camera_keyframes: Query<&mut CameraKeyframe>, - mut q_camera_target_keyframes: Query<&mut CameraTargetKeyframe>, - ) { - if let Some((ent, timestamp)) = UPDATE_KEYFRAME.take_message() { - if let Ok(mut keyframe) = q_camera_keyframes.get_mut(ent) { - **keyframe = timestamp; - } else if let Ok(mut keyframe) = q_camera_target_keyframes.get_mut(ent) { - **keyframe = timestamp; - } else { - warn!("No matching keyframe found for entity: {ent:?}"); - } - } - } - - #[wasm_bindgen(typescript_custom_section)] - const CAMERA_KEYFRAMES_EVENT: &str = r#" -export interface CameraKeyframesEvent extends CustomEvent { - type: "camera-keyframes"; - detail: Record; -} - -declare global { - export interface GlobalEventHandlersEventMap { - "camera-keyframes": CameraKeyframesEvent; - } -} - "#; - - pub(super) fn notify_keyframe_changes( - mut l_notified: Local>, - q_keyframes: Query<(Entity, &CameraKeyframe)>, - q_window: Query<&Window, With>, - ) -> Result<(), String> { - if l_notified.len() == q_keyframes.iter().count() { - return Ok(()); - } - - let Ok(window) = q_window.get_single() else { - return Ok(()); - }; - - l_notified.clear(); - for (ent, timestamp) in q_keyframes.iter() { - l_notified.insert(format!("{ent:?}"), timestamp.0.into()); - } - - wasm::dispatch_to_js(window, "camera-keyframes", &*l_notified) - } -} diff --git a/studio/src/anim/mod.rs b/studio/src/anim/mod.rs index ce98564..f655d3a 100644 --- a/studio/src/anim/mod.rs +++ b/studio/src/anim/mod.rs @@ -9,8 +9,8 @@ use space_editor::space_prefab::editor_registry::EditorRegistryExt; use crate::{hierarchy, scene::SceneElement}; pub use retargeting::*; -mod camera; mod retargeting; +pub mod timeline_animation; pub struct AnimPlugin; impl Plugin for AnimPlugin { @@ -29,19 +29,14 @@ impl Plugin for AnimPlugin { .editor_registry::(); app.add_systems(Update, process_skinned_meshes); - app.insert_resource(GlobalSeekTime(0.)); - app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin)); - - #[cfg(feature = "wasm")] - app.add_systems(Update, wasm::update_global_seek_time); + app.add_plugins(( + retargeting::RetargetingPlugin, + timeline_animation::TimelinePlugin, + )); } } -#[derive(Resource, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)] -#[reflect(Resource)] -pub struct GlobalSeekTime(pub f32); - #[derive(Component, Clone, Copy, Debug, Default, Reflect)] #[reflect(Component)] pub struct Skeleton; @@ -149,26 +144,3 @@ fn process_skinned_meshes( } } } - -#[cfg(feature = "wasm")] -mod wasm { - use bevy::prelude::*; - use wasm_bindgen::prelude::*; - - use crate::wasm::{StaticBuffer, WasmMessageBuffer}; - - use super::GlobalSeekTime; - - static SEEK_TIME: WasmMessageBuffer = WasmMessageBuffer::new(); - - #[wasm_bindgen(js_name = seekTime)] - pub fn seek_time(timestamp: f32) { - SEEK_TIME.write_message(timestamp); - } - - pub(super) fn update_global_seek_time(mut r_seek_time: ResMut) { - if let Some(timestamp) = SEEK_TIME.take_message() { - **r_seek_time = timestamp; - } - } -} diff --git a/studio/src/anim/timeline_animation.rs b/studio/src/anim/timeline_animation.rs new file mode 100644 index 0000000..e44ad7f --- /dev/null +++ b/studio/src/anim/timeline_animation.rs @@ -0,0 +1,836 @@ +use std::time::Duration; + +use bevy::{ + ecs::{ + entity::{EntityMapper, MapEntities}, + reflect::ReflectMapEntities, + }, + prelude::*, + utils::HashSet, +}; + +use bevy_tweening::{ + lens::{TransformPositionLens, TransformRotationLens, TransformScaleLens}, + Animator, EaseMethod, Sequence, Targetable, Tracks, Tween, TweenCompleted, +}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use space_editor::space_prefab::editor_registry::EditorRegistryExt; + +use crate::interaction::{MarkedForDeletion, Selectable}; + +pub struct TimelinePlugin; + +impl Plugin for TimelinePlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .add_event::() + .add_event::(); + + app.insert_resource(GlobalSeekTime { + time: 0., + playing: false, + delta: None, + }) + .add_event::(); + + app.add_systems(PreUpdate, (process_timeline_events, update_tick).chain()); + + app.add_systems( + Update, + ( + process_keyframe_events, + timeline_bound_component_animator_system::, + ), + ); + + app.add_systems(PostUpdate, generate_transform_animation); + + #[cfg(feature = "wasm")] + { + app.add_systems( + First, + ( + wasm::update_global_seek_time, + wasm::update_keyframe_interaction, + ), + ); + app.add_systems( + PostUpdate, + ( + wasm::notify_seek_time_changes.map(bevy::utils::error), + wasm::notify_keyframe_changes.map(bevy::utils::error), + wasm::notify_updated_animation_targets.map(bevy::utils::error), + ), + ); + } + } +} + +const MIN_KEYFRAME_DISTANCE: f32 = 0.01; + +#[allow(dead_code)] +#[derive(Event, Clone, Copy, Debug)] +pub enum TimelineInteractionEvent { + Play, + Pause, + Seek(f32), +} + +#[allow(dead_code)] +#[derive(Event, Clone, Copy, Debug)] +pub enum KeyframeInteractionEvent { + RecordKeyframe { target: Entity }, + MoveKeyframeTime { target: Entity, time: f32 }, + AddEntityTrack { target: Entity }, +} + +#[derive(Resource, Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize)] +#[reflect(Resource)] +pub struct GlobalSeekTime { + pub time: f32, + pub playing: bool, + pub delta: Option, +} + +#[derive(Clone, Debug, Copy, Default, Reflect, PartialEq, Serialize, Deserialize)] +pub enum EaseType { + #[default] + Linear, + CubicHermite, +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component)] +pub struct TransformKeyframe { + incoming_easing: EaseType, + affect_translation: bool, + affect_rotation: bool, + affect_scale: bool, +} + +impl Default for TransformKeyframe { + fn default() -> Self { + Self { + incoming_easing: Default::default(), + affect_translation: true, + affect_rotation: true, + affect_scale: true, + } + } +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component, MapEntities)] +pub struct Keyframe { + target: Entity, + time: f32, +} + +#[derive(Component, Clone, Debug, Default, Reflect, PartialEq, Eq, PartialOrd, Ord)] +#[reflect(Component)] +pub struct KeyframeOrder(u32); + +impl PartialOrd for Keyframe { + fn partial_cmp(&self, other: &Self) -> Option { + self.time.partial_cmp(&other.time) + } +} + +impl MapEntities for Keyframe { + fn map_entities(&mut self, entity_mapper: &mut EntityMapper) { + self.target = entity_mapper.get_or_reserve(self.target); + } +} + +impl Default for Keyframe { + fn default() -> Self { + Self { + target: Entity::PLACEHOLDER, + time: Default::default(), + } + } +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component)] +pub struct TimedAnimationClipAsset { + clip: Handle, + duration: f32, + speed: f32, +} + +impl PartialOrd for TimedAnimationClipAsset { + fn partial_cmp(&self, other: &Self) -> Option { + match self.duration.partial_cmp(&other.duration) { + Some(core::cmp::Ordering::Equal) => {} + ord => return ord, + } + match self.speed.partial_cmp(&other.speed) { + Some(core::cmp::Ordering::Equal) => {} + ord => return ord, + } + None + } +} + +impl Default for TimedAnimationClipAsset { + fn default() -> Self { + Self { + clip: Default::default(), + duration: Default::default(), + speed: 1.0, + } + } +} + +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component)] +pub struct AnimationTarget; + +fn process_timeline_events( + mut e_timeline_event: EventReader, + mut r_global_seek_time: ResMut, +) { + for event in e_timeline_event.read() { + match event { + TimelineInteractionEvent::Play => r_global_seek_time.playing = true, + TimelineInteractionEvent::Pause => { + r_global_seek_time.playing = false; + } + TimelineInteractionEvent::Seek(time) => { + r_global_seek_time.time = time.max(0.); + } + } + + r_global_seek_time.delta = None; + } +} + +fn update_tick(mut r_global_seek_time: ResMut, time: Res