Animation Timeline Initial (#88)

This PR adds an initial animation timeline.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Danny McGee <dannymcgee@gmail.com>
This commit is contained in:
Lee-Orr
2024-03-20 20:53:24 -04:00
committed by GitHub
parent 03f87b2544
commit 0eff115eb5
21 changed files with 1481 additions and 490 deletions
+125 -127
View File
@@ -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<string, number> = {};
/** Keyframe positions (in seconds) */
@property({ attribute: false }) keyframes: Record<string, studio.Keyframe> = {};
@property({ attribute: false }) track_ordered: Record<string, number> = {}
@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<this>): 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")
}<!--<span class="frac">.${
frac.toString(10).padEnd(3, "0")
}</span>-->`;
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`
<div
@@ -291,43 +295,33 @@ export class AnimTimelineElement extends LitElement {
})}
${drag({
button: 1,
start: this.#onDragStart,
move: this.#onDrag,
end: this.#onDragEnd,
start: this.onDragStart,
move: this.onDrag,
end: this.onDragEnd,
})}
tabindex="-1"
>
<div class="clip-region"
style=${styleMap({
left: `${this.#msToPx(this.clipStart)}px`,
right: `${this._renderWidth - this.#msToPx(this.clipEnd)}px`,
})}
></div>
${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`
<div
class=${classMap({
hash: true,
strong,
strong: isMajor,
})}
style=${styleMap({
left: `${pxOffset}px`
left: `${secondsPercentage}%`
})}
></div>
${strong ? html`
${true ? html`
<span class="timestamp"
style=${styleMap({
left: `${pxOffset + 8}px`
left: `calc(${secondsPercentage}% + 8px)`
})}
>
${this.#timestamp(ms)}
${this.timestamp(seconds)}
</span>
` : nothing}
`;
@@ -335,10 +329,10 @@ export class AnimTimelineElement extends LitElement {
<div class="playhead"
${drag({
move: this.#onPlayheadDrag
move: this.onPlayheadDrag
})}
style=${styleMap({
left: `${this.#msToPx(this.playHead)}px`
left: `${100 * (this.playHead - this.rangeStart) / range}%`
})}
tabindex="-1"
>
@@ -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`
<div id=${id}
class=${classMap({
keyframe: true,
selected: this._selectedKeyframe === id,
selected: this._selectedKeyframe === id
})}
style=${styleMap({
left: `${this.#msToPx(keyframe)}px`
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
gridRow: row + 2,
})}
tabindex="-1"
${drag({
move: event => this.#onKeyframeDrag(id, event),
move: event => this.onKeyframeDrag(id, event),
})}
@click=${() => this.selectKeyframe(id)}
></div>
`
`;
}
)}
</div>
`;