Add nodes that ask whether an animation still runs (#233)

A graph could start, pause and stop an animation and hear it finish,
but it could not ask in between whether one runs or how far it is.
Two data nodes answer that now.

- Animation playing takes an object and an animation and gives
  playing, progress and time left (s). It answers at the moment it is
  read: right after Play animation in the same pass it says on, and in
  the frame On animation finished fires it says off.
- With Animation at None it answers for the running animation started
  last on the object.
- Animation length gives the seconds of one pass, from the timeline
  and the frame rate alone.
- The rule is a pure function in src/core/model/animation.ts
  (playerStatus, leadingPlayer). The nodes reach an animation's
  length through a new field animation on GraphLookup.
- animationPort is exported so both nodes show the same picker as
  Play animation.

Decided in the issue: a Once (reset) counts as running while it blends
out, and progress and time left also hold for Loop and Ping-pong, at
the place of the playhead. On Ping-pong progress falls again on the
way back and time left counts to the next turn.

Decided here, the issue says nothing on it:
- A paused animation does not count as running. Progress and time
  left stand still.
- One that Stop animation fades out does not count as running, and
  its time left is 0.
- Time left of a Once (reset) includes its blend out. While it blends
  out progress is 1 and time left is the rest of the fade.

Finding, not fixed: on the node the name of the chosen animation
overlaps the output label progress in the same row. That comes from
the fixed node width in NodeView and was seen in the Fit view only.

Tests:
- Gate: 198 files, 2903 tests (2891 + 12), exit 0
- e2e 1-6: 282 checks passed, 06 has +2 for block 6z-ix
- Nine unit counter-probes fail, 1 to 4 tests each
- The browser counter-probe fails 6z-ix with ["false","false"]
- e2e total 1615 is computed, not measured in a full run. The smokes
  were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 10:57:31 +02:00
1 parent 26cc6324af
commit c1cc80bb45
11 files changed
+437 -4

No files matched your search

+66
View File
@@ -651,6 +651,72 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void
if (animationId === '' || player.animationId === animationId) player.playing = false;
}
/** What a run needs of an animation to say where a player stands in it (#233). */
export interface AnimationClip {
frames: number;
fps: number;
playback: Playback;
}
export function clipOf(asset: AnimationAsset): AnimationClip {
return { frames: frameCount(asset), fps: fpsOf(asset), playback: playbackOf(asset) };
}
export function clipDuration(clip: AnimationClip): number {
return clip.fps > 0 ? clip.frames / clip.fps : 0;
}
/** What `Animation playing` answers (#233). */
export interface PlayerStatus {
playing: boolean;
/** Where the playhead stands on the timeline, 0…1 – on ping-pong's way back it falls again. */
progress: number;
/** Seconds until the playhead reaches the end of its pass, and a `once-reset` has blended out. */
timeLeft: number;
}
export const NOT_PLAYING: PlayerStatus = { playing: false, progress: 0, timeLeft: 0 };
/**
* A paused player is not running, and neither is one `Stop animation` fades out. A `once-reset` pass
* blending back is: it still shows.
*/
function isRunning(player: AnimationPlayer): boolean {
return player.fade ? player.fade.ended === true : player.playing;
}
/**
* The player `Animation playing` answers for: the one of that animation, or for `''` the one started
* last among those running – and when none runs, the one started last at all.
*/
export function leadingPlayer(holder: PlayerHolder, animationId: string): AnimationPlayer | undefined {
const players = (holder.animations ?? []).filter(
(p) => animationId === '' || p.animationId === animationId,
);
const running = players.filter(isRunning);
return (running.length > 0 ? running : players).sort((a, b) => b.order - a.order)[0];
}
export function playerStatus(clip: AnimationClip, player: AnimationPlayer): PlayerStatus {
const playing = isRunning(player);
const fade = player.fade;
if (fade?.ended) return { playing, progress: 1, timeLeft: Math.max(0, fade.seconds - fade.elapsed) };
if (clip.frames <= 0 || clip.fps <= 0) return { playing, progress: 0, timeLeft: 0 };
const head = playheadAt(clip.frames, clip.fps, clip.playback, player.time);
if (clip.playback === 'pingpong') {
const last = clip.frames - 1;
if (last <= 0) return { playing, progress: 0, timeLeft: 0 };
const ahead = head.reverse ? head.frame : last - head.frame;
return { playing, progress: head.frame / last, timeLeft: fade ? 0 : ahead / clip.fps };
}
const blendOut = clip.playback === 'once-reset' ? (player.blendOut ?? 0) : 0;
return {
playing,
progress: Math.min(1, head.frame / clip.frames),
timeLeft: fade ? 0 : Math.max(0, clip.frames - head.frame) / clip.fps + blendOut,
};
}
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
+5
View File
@@ -2,6 +2,7 @@ import type { GraphLookup } from '../graph/lookup';
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
import { pictureSize } from '../properties/aspect';
import { resolveAssetProps } from '../properties/resolve';
import { clipOf } from './animation';
import { isGraphAsset, isVariableOwner } from './Asset';
import { bagWithoutItemTypes } from './Properties';
import { fieldTypings, typeFields, typingOf } from './structTypes';
@@ -45,6 +46,10 @@ export function graphLookup(project: Project): GraphLookup {
const ref = resolvePicture(project, pictureId);
return ref ? pictureSize(ref) : null;
},
animation: (assetId) => {
const asset = findAssetOf(project, assetId, 'animation');
return asset ? clipOf(asset) : null;
},
ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '',
variableTyping: (assetId, key) => {
const owner = findAsset(project, assetId);