Ping-pong turns on the last key, and stepped rows hold the key they last passed (#112)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 07:08:44 +02:00
1 parent f68941fa3a
commit cc14988338
7 files changed
+72 -22

No files matched your search

+22 -9
View File
@@ -27,6 +27,8 @@ export interface PlayheadAt {
frame: number;
/** Only the two `once` modes ever finish. */
finished: boolean;
/** On ping-pong's way back: a value that jumps holds the key the playhead passed last – the one above. */
reverse: boolean;
}
/** Slack for `time * fps`: six ticks of 1/60 s sum to 0.09999999999999999, which at 10 fps is frame 1. */
@@ -67,18 +69,26 @@ export function animationDuration(asset: AnimationAsset): number {
/** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */
export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt {
if (count <= 0) return { frame: 0, finished: playback === 'once' || playback === 'once-reset' };
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false };
if (count <= 0)
return { frame: 0, finished: playback === 'once' || playback === 'once-reset', reverse: false };
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false, reverse: false };
const at = time * fps + STEP_EPSILON;
switch (playback) {
case 'loop':
return { frame: at % count, finished: false };
return { frame: at % count, finished: false, reverse: false };
case 'once':
case 'once-reset':
return at >= count ? { frame: count, finished: true } : { frame: at, finished: false };
return at >= count
? { frame: count, finished: true, reverse: false }
: { frame: at, finished: false, reverse: false };
case 'pingpong': {
const phase = at % (2 * count);
return { frame: phase < count ? phase : 2 * count - phase, finished: false };
// Turns on the last key, so neither end is shown twice: keys on 0…9 play 0…9…1 in 18 frames (#112).
const last = count - 1;
if (last <= 0) return { frame: 0, finished: false, reverse: false };
const phase = at % (2 * last);
return phase < last
? { frame: phase, finished: false, reverse: false }
: { frame: 2 * last - phase, finished: false, reverse: true };
}
}
}
@@ -114,12 +124,14 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
/**
* A row's value at a (fractional) frame: the first value before the first key, the last after the
* last one. `undefined` only for a row without keys.
* last one. A value that jumps holds until the next key in the direction of play: played in
* `reverse`, that is the key above. `undefined` only for a row without keys.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
kind: string,
frame: number,
reverse = false,
): PropertyValue | undefined {
const first = keys[0];
if (!first) return undefined;
@@ -128,7 +140,7 @@ export function trackValueAt(
const next = keys[i]!;
if (frame >= next.frame) continue;
const prev = keys[i - 1]!;
if (!blends(kind)) return prev.value;
if (!blends(kind)) return reverse && frame > prev.frame ? next.value : prev.value;
return blendValue(kind, prev.value, next.value, (frame - prev.frame) / (next.frame - prev.frame));
}
return keys[keys.length - 1]!.value;
@@ -144,8 +156,9 @@ export function trackWriteAt(
kind: string,
frame: number,
base: PropertyValue | undefined,
reverse = false,
): PropertyValue | undefined {
const value = trackValueAt(track.keys, kind, frame);
const value = trackValueAt(track.keys, kind, frame, reverse);
if (value === undefined) return undefined;
const offset = track.relative && kind === 'number' && typeof base === 'number';
return offset ? base + Number(value) : value;
+8 -5
View File
@@ -30,19 +30,21 @@ export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
* container through its instance's edits – the same route an edit in the Scene tab takes, so a row
* aimed into a placed container needs nothing of its own (#109). `undefined` without a root. Past
* the end of a `once-reset` pass nothing is written: the player has put the starting values back.
* `reverse` is ping-pong's way back, as `playheadAt` reports it.
*/
export function previewHolder(
project: Project,
animation: AnimationAsset,
frame: number,
reverse = false,
): ObjectHolder | undefined {
const root = findAsset(project, animation.root);
if (!root) return undefined;
const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation);
const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root);
for (const track of reset ? [] : animation.tracks) {
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame);
else writeObject(project, holder, previewObjectId(root, track.target), track, frame);
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame, reverse);
else writeObject(project, holder, previewObjectId(root, track.target), track, frame, reverse);
}
return holder;
}
@@ -72,10 +74,10 @@ function placedHolder(root: Asset): ObjectHolder {
}
/** A row on the scene itself: its value goes into the copy's own properties. */
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number): void {
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
const def = animatableDefs('scene').find((d) => d.key === track.property);
if (!def) return;
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default);
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default, reverse);
if (value !== undefined) holder.props[track.property] = value;
}
@@ -85,12 +87,13 @@ function writeObject(
id: string,
track: AnimationTrack,
frame: number,
reverse: boolean,
): void {
const ref = objectRef(project, holder, id);
const asset = ref && findAsset(project, ref.object.assetId);
const kind = asset && animatedKind(asset.type, track.property);
if (!ref || !asset || !kind) return;
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property));
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property), reverse);
if (value === undefined) return;
if (isPositionKey(track.property))
ref.setPosition({ ...ref.object.position, [track.property]: Number(value) });