diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss index a041364..ac381ff 100644 --- a/studio-frontend/src/app/anim-editor.element.scss +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -1,21 +1,27 @@ :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 { @@ -58,6 +64,8 @@ } .divider { + grid-column: divider; + grid-row: 1 / span var(--row-count); position: relative; flex: 0 0 8px; margin: { @@ -83,6 +91,8 @@ } } -.timeline { - flex: 1 0 0; -} +.tracklist { + display: grid; + grid-template-columns: subgrid; + grid-row: 2 / span var(--track-count); +} \ 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 40634cd..09cd847 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, type SeekTimeChanged, type UpdateAnimationTargets } from "@storyteller/studio"; import { bind, drag, on } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit"; +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"; @@ -12,6 +12,12 @@ import "./anim-timeline.element"; import styles from "./anim-editor.element.scss?inline"; +const TRACK_ICONS : Record = { + camera: "video", + transform: "up-down-left-right", + light: "light" +}; + @customElement("sts-anim-editor") export class AnimEditorElement extends LitElement { static override styles = unsafeCSS(styles); @@ -19,14 +25,25 @@ export class AnimEditorElement extends LitElement { @state() sidebarWidth = 256; @state() timelineStart = 0; - @state() timelineEnd = 5_000; + @state() timelineEnd = 5; @state() clipStart = 0; @state() clipEnd = 15_000; @state() playHead = 0; @state() playing = false; - @state() keyframes: Record = {}; + + @state() tracks : Record = {}; + + @state() track_ordered : string[] = []; + + @state() keyframes : Record = {}; + + @state() selected_track : string | null = null; + + trackCount(): number { + return Object.keys(this.tracks).length; + } protected override update(changes: PropertyValues): void { if (changes.has("playHead")) @@ -42,14 +59,26 @@ export class AnimEditorElement extends LitElement { this.playing = playing; } - @on("window:camera-keyframes") - onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void { - this.keyframes = keyframes; - } - @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); + + this.tracks = tracks; } onSidebarResize(event: PointerEvent): void { @@ -67,26 +96,12 @@ export class AnimEditorElement extends LitElement { 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; - } - this.playHead = pos; } 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; - } - this.playHead = pos; } @@ -95,6 +110,7 @@ export class AnimEditorElement extends LitElement { } togglePlaying(): void { + console.log("toggling play"); if (this.playing) { studio.pause(); } else { @@ -102,10 +118,16 @@ export class AnimEditorElement extends LitElement { } } - protected override render = (): TemplateResult => html` + protected override render(): TemplateResult { + let track_count = { + '--track-count': `${this.trackCount()}`, + '--row-count': `calc(var(--track-count) + 1)` + }; + return html` -
- `; -} + ` + }; +}; + diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts index de4f447..59cd97e 100644 --- a/studio-frontend/src/app/anim-timeline.element.ts +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -59,11 +59,6 @@ export class AnimTimelineElement extends LitElement { /** The time (in seconds) at the right edge of the timeline view */ @property({ type: Number }) rangeEnd = 5; - /** The time (in seconds) at the beginning of the active clip range */ - @property({ type: Number }) clipStart = 0; - /** The time (in seconds) at the end of the active clip range */ - @property({ type: Number }) clipEnd = 15; - /** The time (in seconds) of the current play-head position */ @property({ type: Number }) playHead = 0; /** Keyframe positions (in seconds) */ @@ -86,7 +81,7 @@ export class AnimTimelineElement extends LitElement { @state() _panning = false; /** How many seconds are represented by each px of horizontal space. */ - pxPerSecond = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); + secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); #resizeObserver: ResizeObserver; @@ -107,12 +102,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.pxPerSecond.invalidate(); + this.secondsPerPx.invalidate(); } super.update(changes); @@ -158,7 +154,7 @@ export class AnimTimelineElement extends LitElement { // 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.pxPerSecond.get()) / currentRange; + const t = ((this._offsetX + origin) * this.secondsPerPx.get()) / currentRange; console.log(`t: ${t}`); console.log(`deltaStart: ${-deltaRange * t}`); console.log(`deltaEnd: ${deltaRange * (1 - t)}`); @@ -169,22 +165,22 @@ export class AnimTimelineElement extends LitElement { 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 pxPerSecond = this.pxPerSecond.get(); + const pxPerSecond = this.secondsPerPx.get(); const rangeStart = this.rangeStart - deltaPx * pxPerSecond; const rangeEnd = this.rangeEnd - deltaPx * pxPerSecond; @@ -192,15 +188,18 @@ export class AnimTimelineElement extends LitElement { this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd })); } - #onPlayheadDrag(event: PointerEvent): void { - const deltaSeconds = event.movementX * this.pxPerSecond.get(); - const updated = this.playHead + deltaSeconds; + 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 deltaSeconds = event.movementX * this.pxPerSecond.get(); + onKeyframeDrag(id: string, event: PointerEvent): void { + const deltaSeconds = event.movementX * this.secondsPerPx.get(); const updated = this.keyframes[id] + deltaSeconds; studio.updateKeyframe(id, updated); @@ -223,59 +222,65 @@ export class AnimTimelineElement extends LitElement { /** * Format a time value (in seconds) for displaying in the timeline view */ - #timestamp(seconds: number) { + timestamp(seconds: number) { const sign = seconds < 0 ? "-" : ""; seconds = Math.abs(seconds); - let s = Math.floor(seconds); - let frac = Math.floor(seconds * 1000 - s * 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 seconds) to a px offset from the left edge of the view */ - #secondsToPx(seconds: number): number { - return this._offsetX + seconds * this.pxPerSecond.get(); + secondsToPx(seconds: number): number { + return this._offsetX + seconds * this.secondsPerPx.get(); } - protected override render = () => { - const pxPerSecond = this.pxPerSecond.get(); + protected override render() { + const { + subdivision: subdivisionScale, + major: majorSubdivisionScale, + } = { + subdivision: 0.5, + major: 1, + }; + + const scaledStart = this.rangeStart / subdivisionScale; + const scaledEnd = this.rangeEnd / subdivisionScale; const range = this.rangeEnd - this.rangeStart; - const subdivsCount = Math.floor(this._renderWidth / 64); - const pxPerSubdiv = this._renderWidth / subdivsCount; - const secondsPerSubdiv = pxPerSecond / pxPerSubdiv; + console.log("Scaled Start", scaledStart, "Scaled End", scaledEnd, "Range", range); + + let subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean}> = []; + let currentOffset = scaledStart; + let currentOffsetSeconds = this.rangeStart; - console.log("Seconds per Subdiv: ", secondsPerSubdiv); - const subdivs = new Array(subdivsCount + 2).fill(null); + while (currentOffset < scaledEnd) { + const currentSeconds = currentOffsetSeconds; + const isMajor = currentOffsetSeconds % majorSubdivisionScale === 0; + subdivisions.push({ seconds: currentSeconds, fraction: currentSeconds / range, isMajor }); + let nextSubdiv = Math.ceil(currentOffset); + if (Math.abs(nextSubdiv - currentOffset) < 0.1) { + nextSubdiv += 1; + } + currentOffset = nextSubdiv; + currentOffsetSeconds = currentOffset * subdivisionScale; + } - const fracPxOffset = this._offsetX % pxPerSubdiv; - - let startingIdx = -Math.floor(this._offsetX / pxPerSubdiv); - if (fracPxOffset < 0) - startingIdx -= 2; - else - startingIdx -= 1; + console.log("Subdivisions", subdivisions.length, subdivisions); return html`
- ${subdivs.map((_, idx) => { - const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset; - const seconds = (startingIdx + idx) * secondsPerSubdiv; - - const secondsFrac = Math.abs(Math.round(seconds)); - const strong = secondsFrac === 0; + ${subdivisions.map(({ seconds, fraction, isMajor}, idx) => { + const secondsPercentage = fraction * 100; return html`
- ${strong ? html` + ${true ? html` - ${seconds} + ${this.timestamp(seconds)} ` : nothing} `; @@ -329,10 +328,10 @@ export class AnimTimelineElement extends LitElement {
@@ -350,11 +349,11 @@ export class AnimTimelineElement extends LitElement { selected: this._selectedKeyframe === id, })} style=${styleMap({ - left: `${this.#secondsToPx(keyframe)}px` + left: `${this.secondsToPx(keyframe)}px` })} tabindex="-1" ${drag({ - move: event => this.#onKeyframeDrag(id, event), + move: event => this.onKeyframeDrag(id, event), })} @click=${() => this.selectKeyframe(id)} >