mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-18 07:35:19 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, number> = {};
|
||||
@state() playing = false;
|
||||
|
||||
@state() tracks : Record<string, {name?: string, target_type: studio.SceneElement }> = {};
|
||||
|
||||
@state() track_ordered : string[] = [];
|
||||
|
||||
@state() keyframes : Record<string, Keyframe> = {};
|
||||
|
||||
@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<this>): void {
|
||||
if (changes.has("playHead"))
|
||||
studio.seekTime(this.playHead);
|
||||
|
||||
if (changes.has("selectedTrack")) {
|
||||
const treeSelectionChanges: PropertyValues<TreeSelectionProvider> = 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<string, number> = {};
|
||||
|
||||
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`
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name=${name}
|
||||
icon=${icon}
|
||||
@tree-select=${() => {
|
||||
studio.select(entity);
|
||||
}}
|
||||
.id=${entity}
|
||||
></sts-tree>
|
||||
`;
|
||||
});
|
||||
|
||||
if (canAddSelected) {
|
||||
tracklist.push(html`
|
||||
<sts-tree class=${`track`}
|
||||
role="button"
|
||||
.name=${`Add Track: ${this.sceneData?.selectedObject?.name ?? canAddSelected}`}
|
||||
icon="plus-large"
|
||||
@click=${this.addTarget}></sts-tree>
|
||||
`);
|
||||
}
|
||||
|
||||
const none_selected = !selected;
|
||||
|
||||
return html`
|
||||
<div class="sidebar"
|
||||
style=${styleMap({
|
||||
width: `${this.sidebarWidth}px`,
|
||||
...timeline_css_variables
|
||||
})}
|
||||
>
|
||||
<sts-toolbar>
|
||||
@@ -93,6 +324,7 @@ export class AnimEditorElement extends LitElement {
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
?disabled=${none_selected}
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
@@ -110,9 +342,10 @@ export class AnimEditorElement extends LitElement {
|
||||
@click=${this.prevKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="play"
|
||||
icon="play"
|
||||
icon=${this.playing ? "pause" : "play" }
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
@click=${this.togglePlaying}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-step"
|
||||
@@ -129,32 +362,27 @@ export class AnimEditorElement extends LitElement {
|
||||
</sts-toolbar-group>
|
||||
</sts-toolbar>
|
||||
<div class="tracklist" role="list">
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera"
|
||||
icon="video"
|
||||
></sts-tree>
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera Target"
|
||||
icon="crosshairs"
|
||||
></sts-tree>
|
||||
${tracklist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"
|
||||
<div class="divider" style=${styleMap(timeline_css_variables)}
|
||||
${drag({
|
||||
move: this.onSidebarResize,
|
||||
})}
|
||||
></div>
|
||||
|
||||
<sts-anim-timeline class="timeline"
|
||||
style=${styleMap(timeline_css_variables)}
|
||||
.rangeStart=${bind(this, "timelineStart")}
|
||||
.rangeEnd=${bind(this, "timelineEnd")}
|
||||
.clipStart=${this.clipStart}
|
||||
.clipEnd=${this.clipEnd}
|
||||
.playHead=${bind(this, "playHead")}
|
||||
.keyframes=${bind(this, "keyframes")}
|
||||
.track_ordered=${track_order_hashed}
|
||||
></sts-anim-timeline>
|
||||
`;
|
||||
}
|
||||
`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "";
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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<ButtonElement>();
|
||||
#mediaLibraryRef = createRef<DialogElement>();
|
||||
@@ -136,7 +136,8 @@ export class AppElement extends LitElement {
|
||||
this.showHelp = !this.showHelp;
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
protected override render() {
|
||||
return html`
|
||||
<sts-overlay-provider>
|
||||
<sts-canvas-provider class="app-root">
|
||||
<sts-scene-data-provider>
|
||||
@@ -290,4 +291,5 @@ export class AppElement extends LitElement {
|
||||
</sts-canvas-provider>
|
||||
</sts-overlay-provider>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user