mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Auto keyframe/scrubbing/snapping (#107)
Co-authored-by: Danny McGee <dannymcgee@gmail.com>
This commit is contained in:
@@ -46,6 +46,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-autokey {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
background: #0006;
|
||||
color: #FFF;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
background: #90363F;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #7A2C34E3;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-nav {
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
@@ -14,7 +14,7 @@ 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> = {
|
||||
const TRACK_ICONS: Record<string, string> = {
|
||||
camera: "video",
|
||||
transform: "up-down-left-right",
|
||||
light: "light"
|
||||
@@ -30,10 +30,10 @@ function isOutsideEntity(
|
||||
@customElement("sts-anim-editor")
|
||||
@provide(TREE_SELECTION_PROVIDER)
|
||||
export class AnimEditorElement extends LitElement
|
||||
implements TreeSelectionProvider {
|
||||
@inject(SCENE_DATA_PROVIDER)
|
||||
sceneData?: SceneDataProvider;
|
||||
|
||||
implements TreeSelectionProvider {
|
||||
@inject(SCENE_DATA_PROVIDER)
|
||||
sceneData?: SceneDataProvider;
|
||||
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@state() sidebarWidth = 256;
|
||||
@@ -47,14 +47,16 @@ implements TreeSelectionProvider {
|
||||
@state() playHead = 0;
|
||||
@state() playing = false;
|
||||
|
||||
@state() tracks : Record<string, {name?: string, target_type: studio.SceneElement, animation_target: studio.AnimationTarget }> = {};
|
||||
|
||||
@state() track_ordered : [string, studio.AnimationTarget][] = [];
|
||||
@state() tracks: Record<string, { name?: string, target_type: studio.SceneElement, animation_target: studio.AnimationTarget }> = {};
|
||||
|
||||
@state() keyframes : Record<string, Keyframe> = {};
|
||||
@state() track_ordered: [string, studio.AnimationTarget][] = [];
|
||||
|
||||
@state() keyframes: Record<string, Keyframe> = {};
|
||||
|
||||
@state() selectedTrack?: { track: string } | { outsideEntity: string };
|
||||
|
||||
@state() keyingMode: "manual" | "automatic" = "manual";
|
||||
|
||||
trackCount(): number {
|
||||
return Object.keys(this.tracks).length;
|
||||
}
|
||||
@@ -78,12 +80,12 @@ implements TreeSelectionProvider {
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
setPlayTime(time: number) : void {
|
||||
setPlayTime(time: number): void {
|
||||
studio.seekTime(time);
|
||||
}
|
||||
|
||||
@on("window:seek-time-changed")
|
||||
onSeekTimeChanged({ detail: {time, playing} }: SeekTimeChanged ): void {
|
||||
onSeekTimeChanged({ detail: { time, playing } }: SeekTimeChanged): void {
|
||||
console.log("Seek Time Changed", time, playing);
|
||||
this.playHead = time;
|
||||
this.playing = playing;
|
||||
@@ -97,29 +99,29 @@ implements TreeSelectionProvider {
|
||||
}
|
||||
|
||||
@on("window:update-animation-targets")
|
||||
onUpdateAnimationTargets({detail: { added, removed}}: UpdateAnimationTargets) : void {
|
||||
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};
|
||||
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).map(entity => [entity,tracks[entity].animation_target]);
|
||||
this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animation_target]);
|
||||
|
||||
if (typeof this.selectedTrack === "object") {
|
||||
if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) {
|
||||
this.selectedTrack = { outsideEntity: this.selectedTrack.track};
|
||||
this.selectedTrack = { outsideEntity: this.selectedTrack.track };
|
||||
} else if (isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity in tracks) {
|
||||
this.selectedTrack = { track: this.selectedTrack.outsideEntity};
|
||||
this.selectedTrack = { track: this.selectedTrack.outsideEntity };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,13 +129,13 @@ implements TreeSelectionProvider {
|
||||
}
|
||||
|
||||
@on("window:update-keyframes")
|
||||
onUpdateKeyframes({detail: {added, removed}}: studio.UpdateKeyframes): void {
|
||||
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};
|
||||
let keyframes = { ...this.keyframes };
|
||||
for (let keyframe of addedKeyframes) {
|
||||
keyframes[keyframe] = added[keyframe];
|
||||
}
|
||||
@@ -162,14 +164,20 @@ implements TreeSelectionProvider {
|
||||
this.clipEnd = Math.max(max, min);
|
||||
}
|
||||
|
||||
|
||||
@on("window:keying-mode-changed")
|
||||
onKeyingModeChanged({ detail: { automatic } }: studio.UpdateKeyingMode): void {
|
||||
console.log("Keying Mode Changed", automatic);
|
||||
this.keyingMode = automatic ? "automatic" : "manual";
|
||||
}
|
||||
|
||||
|
||||
@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};
|
||||
this.selectedTrack = { outsideEntity: id };
|
||||
} else {
|
||||
this.selectedTrack = undefined;
|
||||
}
|
||||
@@ -206,7 +214,7 @@ implements TreeSelectionProvider {
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time < pos && time > closest_keyframe ) {
|
||||
if (time < pos && time > closest_keyframe) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +237,7 @@ implements TreeSelectionProvider {
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time > pos && time < closest_keyframe ) {
|
||||
if (time > pos && time < closest_keyframe) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
}
|
||||
@@ -262,8 +270,8 @@ implements TreeSelectionProvider {
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
|
||||
const track_order_hashed : Record<string, number> = {};
|
||||
const track_animation_target_order_hashed : Record<studio.AnimationTarget, number> = {};
|
||||
const track_order_hashed: Record<string, number> = {};
|
||||
const track_animation_target_order_hashed: Record<studio.AnimationTarget, number> = {};
|
||||
|
||||
for (const [index, [track, target]] of this.track_ordered.entries()) {
|
||||
track_order_hashed[track] = index;
|
||||
@@ -277,7 +285,7 @@ implements TreeSelectionProvider {
|
||||
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})`,
|
||||
@@ -317,19 +325,34 @@ implements TreeSelectionProvider {
|
||||
return html`
|
||||
<div class="sidebar"
|
||||
style=${styleMap({
|
||||
width: `${this.sidebarWidth}px`,
|
||||
...timeline_css_variables
|
||||
})}
|
||||
width: `${this.sidebarWidth}px`,
|
||||
...timeline_css_variables
|
||||
})}
|
||||
>
|
||||
<sts-toolbar>
|
||||
<sts-toolbar-group>
|
||||
<sts-button class="btn-keyframe"
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
?disabled=${none_selected}
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
${this.keyingMode === "automatic" ? html`
|
||||
<sts-button class="btn-autokey active"
|
||||
icon="record"
|
||||
aria-label="Auto Keyframe"
|
||||
title="Auto Keyframe"
|
||||
@click=${studio.toggleAutoKey}
|
||||
></sts-button>
|
||||
` : html`
|
||||
<sts-button class="btn-keyframe"
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
?disabled=${none_selected}
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="btn-autokey"
|
||||
icon="record"
|
||||
aria-label="Auto Keyframe"
|
||||
title="Auto Keyframe"
|
||||
@click=${studio.toggleAutoKey}
|
||||
></sts-button>
|
||||
`}
|
||||
</sts-toolbar-group>
|
||||
<sts-toolbar-group class="timeline-nav">
|
||||
<sts-button
|
||||
@@ -345,7 +368,7 @@ implements TreeSelectionProvider {
|
||||
@click=${this.prevKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="play"
|
||||
icon=${this.playing ? "pause" : "play" }
|
||||
icon=${this.playing ? "pause" : "play"}
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
@click=${this.togglePlaying}
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-nav-dragger {
|
||||
grid-row: 1 / span 1;
|
||||
grid-column: 1 / span 1;
|
||||
background: #000;
|
||||
opacity: 0.1;
|
||||
height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.hash {
|
||||
|
||||
@@ -64,9 +64,12 @@ export class AnimTimelineElement extends LitElement {
|
||||
@property({ type: Number }) clipStart = 0;
|
||||
/** The time (in seconds) at the end of the clip */
|
||||
@property({ type: Number }) clipEnd = 5;
|
||||
/** The number of frames per second for this animation clip */
|
||||
@property({ type: Number }) clipFps = 24;
|
||||
|
||||
/** The time (in seconds) of the current play-head position */
|
||||
@property({ type: Number }) playHead = 0;
|
||||
|
||||
/** Keyframe positions (in seconds) */
|
||||
@property({ attribute: false }) keyframes: Record<string, studio.Keyframe> = {};
|
||||
@property({ attribute: false }) track_ordered: Record<string, number> = {};
|
||||
@@ -82,6 +85,7 @@ export class AnimTimelineElement extends LitElement {
|
||||
* setting the cursor style to the grabby hand.
|
||||
*/
|
||||
@state() _panning = false;
|
||||
@state() _scrubbing = false;
|
||||
|
||||
/** How many seconds are represented by each px of horizontal space. */
|
||||
secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
@@ -90,13 +94,13 @@ export class AnimTimelineElement extends LitElement {
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#updateRenderWidth());
|
||||
this.#resizeObserver = new ResizeObserver(() => this.updateRenderWidth());
|
||||
this.#resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#updateRenderWidth();
|
||||
this.updateRenderWidth();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -122,12 +126,10 @@ export class AnimTimelineElement extends LitElement {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.ctrlKey) {
|
||||
this.#zoomTimeline(event.clientX, event.deltaY);
|
||||
if (event.ctrlKey || Math.abs(event.deltaY ) > 0) {
|
||||
this.zoomTimeline(event.clientX, event.deltaY);
|
||||
} else if (event.deltaX) {
|
||||
this.#pan(-event.deltaX);
|
||||
} else if (event.shiftKey && event.deltaY) {
|
||||
this.#pan(-event.deltaY);
|
||||
this.pan(-event.deltaX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ export class AnimTimelineElement extends LitElement {
|
||||
studio.select(id);
|
||||
}
|
||||
|
||||
#zoomTimeline(origin: number, delta: number): void {
|
||||
zoomTimeline(origin: number, delta: number): void {
|
||||
const currentRange = this.rangeEnd - this.rangeStart;
|
||||
const elementRect = this.getBoundingClientRect();
|
||||
const focusPoint = (origin - elementRect.x) / elementRect.width;
|
||||
@@ -165,19 +167,37 @@ export class AnimTimelineElement extends LitElement {
|
||||
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
onDragStart(): void {
|
||||
onMidDragStart(): void {
|
||||
this._panning = true;
|
||||
}
|
||||
|
||||
onDragEnd(): void {
|
||||
onMidDragEnd(): void {
|
||||
this._panning = false;
|
||||
}
|
||||
|
||||
onDrag(event: PointerEvent): void {
|
||||
this.#pan(event.movementX);
|
||||
onMidDrag(event: PointerEvent): void {
|
||||
this.pan(event.movementX);
|
||||
}
|
||||
|
||||
#pan(deltaPx: number): void {
|
||||
onDragStart(): void {
|
||||
this._scrubbing = true;
|
||||
}
|
||||
|
||||
onDragEnd(): void {
|
||||
this._scrubbing = false;
|
||||
}
|
||||
|
||||
onDrag(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 = Math.round(seconds * this.clipFps) / this.clipFps;
|
||||
|
||||
studio.seekTime(updated);
|
||||
}
|
||||
|
||||
pan(deltaPx: number): void {
|
||||
const deltaChange = deltaPx * 0.001;
|
||||
const scaledChange = (this.rangeEnd - this.rangeStart) * deltaChange;
|
||||
|
||||
@@ -198,7 +218,17 @@ export class AnimTimelineElement extends LitElement {
|
||||
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;
|
||||
const updated = Math.round(seconds * this.clipFps) / this.clipFps;
|
||||
|
||||
studio.seekTime(updated);
|
||||
}
|
||||
|
||||
onSetPlayhead(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 = Math.round(seconds * this.clipFps) / this.clipFps;
|
||||
|
||||
studio.seekTime(updated);
|
||||
}
|
||||
@@ -208,102 +238,132 @@ export class AnimTimelineElement extends LitElement {
|
||||
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;
|
||||
const final_time = Math.round(time * this.clipFps) / this.clipFps;
|
||||
|
||||
studio.moveKeyframeTime(id, time);
|
||||
studio.moveKeyframeTime(id, final_time);
|
||||
}
|
||||
|
||||
#updateRenderWidth(): void {
|
||||
updateRenderWidth(): void {
|
||||
this._renderWidth = this.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time value (in seconds) for displaying in the timeline view
|
||||
*/
|
||||
timestamp(seconds: number) {
|
||||
timestamp(seconds: number, major: boolean) {
|
||||
const sign = seconds < 0 ? "-" : "";
|
||||
seconds = Math.abs(seconds);
|
||||
|
||||
let minutes = Math.floor(seconds / 60);
|
||||
let secondsInMinute = seconds % 60;
|
||||
let flooredSeconds = Math.floor(secondsInMinute);
|
||||
let fraction = Math.floor(100 * (secondsInMinute - flooredSeconds));
|
||||
let fraction = Math.round(this.clipFps * (secondsInMinute - flooredSeconds));
|
||||
|
||||
return html`${
|
||||
sign
|
||||
}${
|
||||
}${major || fraction == 0 ? html`${
|
||||
minutes.toString(10).padStart(2, "0")
|
||||
}:${
|
||||
flooredSeconds.toString(10).padStart(2, "0")
|
||||
}.${
|
||||
fraction.toString(10).padStart(2, "0")
|
||||
}`;
|
||||
}` : ``} ${fraction != 0 ? html`f${
|
||||
fraction.toString(10)
|
||||
}` : `` }`;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const range = this.rangeEnd - this.rangeStart;
|
||||
|
||||
let subdivisionScale = 0.5;
|
||||
let majorSubdivisionScale = 1;
|
||||
let fps = this.clipFps;
|
||||
|
||||
let frameRange = Math.ceil(range * this.clipFps);
|
||||
|
||||
let spf = 1 / fps;
|
||||
|
||||
let majorSubdivisionSize = this.clipFps;
|
||||
let minorSubdivisionSize = this.clipFps / 2;
|
||||
let unlablelledSubdivisionSize = this.clipFps / 4;
|
||||
|
||||
if (range < 0.5) {
|
||||
subdivisionScale = 0.05;
|
||||
majorSubdivisionScale = 0.1;
|
||||
majorSubdivisionSize = this.clipFps / 2;
|
||||
minorSubdivisionSize = this.clipFps / 12;
|
||||
unlablelledSubdivisionSize = 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;
|
||||
majorSubdivisionSize = this.clipFps / 2;
|
||||
minorSubdivisionSize = this.clipFps / 4;
|
||||
unlablelledSubdivisionSize = 2;
|
||||
} else if (range < 5) {
|
||||
majorSubdivisionSize = this.clipFps;
|
||||
minorSubdivisionSize = this.clipFps / 2;
|
||||
unlablelledSubdivisionSize = this.clipFps / 4;
|
||||
} else if (range < 10) {
|
||||
majorSubdivisionSize = this.clipFps * 2;
|
||||
minorSubdivisionSize = this.clipFps;
|
||||
unlablelledSubdivisionSize = this.clipFps / 2;
|
||||
} else if (range < 60) {
|
||||
majorSubdivisionSize = this.clipFps * 10;
|
||||
minorSubdivisionSize = this.clipFps * 5;
|
||||
unlablelledSubdivisionSize = this.clipFps;
|
||||
} else if (range < 120) {
|
||||
majorSubdivisionSize = this.clipFps * 20;
|
||||
minorSubdivisionSize = this.clipFps * 10;
|
||||
unlablelledSubdivisionSize = this.clipFps * 2;
|
||||
} else {
|
||||
majorSubdivisionSize = Math.ceil(frameRange / 10);
|
||||
minorSubdivisionSize = Math.ceil(frameRange / 20);
|
||||
unlablelledSubdivisionSize = Math.ceil(frameRange / 40);
|
||||
}
|
||||
|
||||
const scaledStart = this.rangeStart / subdivisionScale;
|
||||
const scaledEnd = this.rangeEnd / subdivisionScale;
|
||||
|
||||
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 subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean, isLabelled: boolean}> = [];
|
||||
|
||||
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;
|
||||
let initialFrame = Math.ceil(this.rangeStart * this.clipFps);
|
||||
|
||||
for (let i = 0; i < frameRange; i++) {
|
||||
let frame = i + initialFrame;
|
||||
let seconds = frame * spf;
|
||||
let fraction = (seconds - this.rangeStart) / range;
|
||||
if (frame % majorSubdivisionSize === 0) {
|
||||
subdivisions.push({
|
||||
seconds,
|
||||
fraction,
|
||||
isMajor: true,
|
||||
isLabelled: true
|
||||
});
|
||||
} else if (frame % minorSubdivisionSize === 0) {
|
||||
subdivisions.push({
|
||||
seconds,
|
||||
fraction,
|
||||
isMajor: false,
|
||||
isLabelled: true
|
||||
});
|
||||
} else if (frame % unlablelledSubdivisionSize === 0) {
|
||||
subdivisions.push({seconds, fraction, isMajor: false, isLabelled: false });
|
||||
}
|
||||
currentOffset = nextSubdiv;
|
||||
currentOffsetSeconds = currentOffset * subdivisionScale;
|
||||
}
|
||||
|
||||
console.log("Subdivisions", subdivisions.length, subdivisions);
|
||||
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
wrapper: true,
|
||||
panning: this._panning,
|
||||
panning: this._panning || this._scrubbing,
|
||||
})}
|
||||
${drag({
|
||||
button: 1,
|
||||
start: this.onMidDragStart,
|
||||
move: this.onMidDrag,
|
||||
end: this.onMidDragEnd,
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="timeline-nav-dragger"
|
||||
${drag({
|
||||
button: 0,
|
||||
start: this.onDragStart,
|
||||
move: this.onDrag,
|
||||
end: this.onDragEnd,
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
@click=${this.onSetPlayhead}></div>
|
||||
|
||||
${subdivisions.map(({ seconds, fraction, isMajor}, idx) => {
|
||||
${subdivisions.map(({ seconds, fraction, isMajor, isLabelled}, idx) => {
|
||||
const secondsPercentage = fraction * 100;
|
||||
|
||||
return html`
|
||||
@@ -316,13 +376,13 @@ export class AnimTimelineElement extends LitElement {
|
||||
left: `${secondsPercentage}%`
|
||||
})}
|
||||
></div>
|
||||
${true ? html`
|
||||
${isMajor || isLabelled ? html`
|
||||
<span class="timestamp"
|
||||
style=${styleMap({
|
||||
left: `calc(${secondsPercentage}% + 8px)`
|
||||
})}
|
||||
>
|
||||
${this.timestamp(seconds)}
|
||||
${this.timestamp(seconds, isMajor)}
|
||||
</span>
|
||||
` : nothing}
|
||||
`;
|
||||
|
||||
@@ -43,6 +43,7 @@ import farMountainSun from "@fortawesome/fontawesome-pro/svgs/regular/mountain-s
|
||||
import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/arrow-right-to-bracket.svg?raw";
|
||||
import farMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw";
|
||||
import fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.svg?raw";
|
||||
import fasRecord from "@fortawesome/fontawesome-pro/svgs/solid/circle.svg?raw";
|
||||
|
||||
import customCylinder from "../assets/icons/cylinder.svg?raw";
|
||||
import customSphere from "../assets/icons/sphere.svg?raw";
|
||||
@@ -115,6 +116,7 @@ function renderSvg(iconId: string) {
|
||||
"video": () => farVideo,
|
||||
"volume": () => farVolume,
|
||||
"xmark": () => fasXmark,
|
||||
"record": () => fasRecord,
|
||||
_: () => null,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ impl Plugin for TimelinePlugin {
|
||||
.register_type::<EaseType>()
|
||||
.register_type::<uuid::Uuid>()
|
||||
.add_event::<TimelineInteractionEvent>()
|
||||
.add_event::<KeyframeInteractionEvent>();
|
||||
.add_event::<KeyframeInteractionEvent>()
|
||||
.add_state::<KeyingMode>();
|
||||
|
||||
app.insert_resource(GlobalSeekTime {
|
||||
time: 0.,
|
||||
@@ -48,6 +49,7 @@ impl Plugin for TimelinePlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
process_auto_key.run_if(in_state(KeyingMode::Auto)),
|
||||
process_keyframe_events,
|
||||
timeline_bound_component_animator_system::<Transform>,
|
||||
),
|
||||
@@ -70,6 +72,7 @@ impl Plugin for TimelinePlugin {
|
||||
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),
|
||||
wasm::notify_keying_mode_changed.map(bevy::utils::error),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -84,6 +87,7 @@ pub enum TimelineInteractionEvent {
|
||||
Play,
|
||||
Pause,
|
||||
Seek(f32),
|
||||
ToggleAutoKey,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -102,6 +106,13 @@ pub struct GlobalSeekTime {
|
||||
pub delta: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(States, Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum KeyingMode {
|
||||
#[default]
|
||||
Manual,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Default, Reflect, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EaseType {
|
||||
#[default]
|
||||
@@ -246,6 +257,8 @@ impl FromStr for AnimationTarget {
|
||||
fn process_timeline_events(
|
||||
mut e_timeline_event: EventReader<TimelineInteractionEvent>,
|
||||
mut r_global_seek_time: ResMut<GlobalSeekTime>,
|
||||
key_mode: Res<State<KeyingMode>>,
|
||||
mut next_keying_mode: ResMut<NextState<KeyingMode>>,
|
||||
) {
|
||||
for event in e_timeline_event.read() {
|
||||
match event {
|
||||
@@ -256,6 +269,12 @@ fn process_timeline_events(
|
||||
TimelineInteractionEvent::Seek(time) => {
|
||||
r_global_seek_time.time = time.max(0.);
|
||||
}
|
||||
TimelineInteractionEvent::ToggleAutoKey => {
|
||||
next_keying_mode.set(match key_mode.get() {
|
||||
KeyingMode::Manual => KeyingMode::Auto,
|
||||
KeyingMode::Auto => KeyingMode::Manual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
r_global_seek_time.delta = None;
|
||||
@@ -344,6 +363,22 @@ fn process_keyframe_events(
|
||||
}
|
||||
}
|
||||
|
||||
fn process_auto_key(
|
||||
changed: Query<Entity, (With<AnimationTarget>, Changed<Transform>)>,
|
||||
r_global_seek_time: Res<GlobalSeekTime>,
|
||||
mut event: EventWriter<KeyframeInteractionEvent>,
|
||||
) {
|
||||
if r_global_seek_time.playing || r_global_seek_time.is_changed() {
|
||||
info!("Skipping Auto Key");
|
||||
return;
|
||||
}
|
||||
|
||||
for entity in &changed {
|
||||
info!("Setting Auto Key");
|
||||
event.send(KeyframeInteractionEvent::RecordKeyframe { target: entity })
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_transform_animation(
|
||||
mut commands: Commands,
|
||||
keyframes: Query<
|
||||
@@ -578,7 +613,7 @@ mod wasm {
|
||||
};
|
||||
|
||||
use super::{
|
||||
AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent,
|
||||
AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent, KeyingMode,
|
||||
TimedAnimationClipAsset, TimelineInteractionEvent, TransformKeyframe,
|
||||
};
|
||||
|
||||
@@ -601,6 +636,11 @@ mod wasm {
|
||||
SEEK_TIME.write_message(TimelineInteractionEvent::Pause);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = toggleAutoKey)]
|
||||
pub fn toggle_auto_key() {
|
||||
SEEK_TIME.write_message(TimelineInteractionEvent::ToggleAutoKey);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = recordKeyframe)]
|
||||
pub fn record_keyframe(entities: Vec<String>) {
|
||||
KEYFRAME_EVENT.write_message(
|
||||
@@ -699,6 +739,13 @@ mod wasm {
|
||||
removed: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateKeyingMode extends CustomEvent {
|
||||
type: "keying-mode-changed",
|
||||
detail: {
|
||||
automatic: boolean
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
@@ -722,6 +769,11 @@ mod wasm {
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct UpdateKeyingMode {
|
||||
automatic: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TimedAnimationClipInfo {
|
||||
entity: String,
|
||||
@@ -914,4 +966,28 @@ mod wasm {
|
||||
&serde_json::to_value(r_global_seek_time.as_ref()).map_err(|e| format!("{e}"))?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn notify_keying_mode_changed(
|
||||
r_keying_mode: Res<State<KeyingMode>>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if !r_keying_mode.is_changed() {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
crate::wasm::dispatch_to_js(
|
||||
window,
|
||||
"keying-mode-changed",
|
||||
&serde_json::to_value(UpdateKeyingMode {
|
||||
automatic: match r_keying_mode.get() {
|
||||
KeyingMode::Manual => false,
|
||||
KeyingMode::Auto => true,
|
||||
},
|
||||
})
|
||||
.map_err(|e| format!("{e}"))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user