Let an animation drive a sprite's video picture by picture (#404)
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Failing after 16m12s

A video used to run on its own clock as soon as a sprite wore it. An
animation could switch the sprite on and off, but never say which
picture shows at which frame. Cutting a video in an animation needs
the opposite: the animation sets the time and the video follows.

- Sprites wearing a video or a clip get two rows, Video clock and
  Video time. They show only while the texture holds a video.
- With Video clock on Driven the video does not play by itself. It
  shows the picture at Video time, counted from the clip's start.
  Past the end it goes round with Loop and holds the last picture
  without.
- Video time is a number like any other, so keys, step, easing and
  curves give stills, slow motion and ramps.
- A driven sprite gets a player of its own, keyed by the wearer's
  name. The same clip can run twice at different times.
- Following has two ways (`videoFollow.ts`). Running forward at 0.25x
  to 4x the video plays at that rate and is nudged. A still, a scrub
  or a step backwards seeks, and only the newest seek counts.
- Sound is heard only near 1x. The animation tab now plays it too, so
  a cut can follow the sound. Scrubbing is silent.
- Scene, game view, the inspector's PREVIEW and the shader preview
  show the driven picture. In a running game Play animation sets it.

Found on the way:

- After a scrub the video in the animation tab kept running. The
  preview redrew only while the playhead moved, so nobody asked for
  the video once it stood. `AnimationEditor.tick` now redraws it every
  frame while it shows a video.
- Every sprite has two more defaults (`videoClock: 'free'`,
  `videoTime: 0`). Eight tests in five files pinned the old list and
  are updated.

Tests:
- Unit: video-clock and video-follow new, 4 files with 42 tests green.
- e2e 66 (melektaus-atx): ALL PASSED, 22 checks, 3 new. 66i: two
  sprites on one clip stand at 0.2 s red and 0.7 s green. 66j: a
  scrub to frames 2, 7, 12 and 3 shows red, green, blue, red, and
  each colour still stands 0.7 s later. 66k: playing runs 10-30 ms
  behind at rate 1.00-1.03 and is not muted.
- Counterchecks: a seek that keeps the rate turns the seek test red.
  Not seeing Driven turns 66i, 66j and 66k red.
- Gate (melektaus-atx): exit 0, 297 files, 4546 tests.
- Not tested in the browser: Play animation in a running game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 14:23:07 +02:00
1 parent a62d1d0faf
commit c9e8a3f6ac
24 files changed
+659 -41

No files matched your search

+120 -11
View File
@@ -6,17 +6,44 @@
* In the editor a video plays without sound, as a preview. A run starts every player over at its
* clip's start and lets it be heard through the master bus; a pause holds them, and the end of the
* run makes them quiet previews again.
*
* A sprite whose Video clock is driven (#404) has a player of its own instead, which shows the
* picture at its Video time and leaves runs and pauses to it (see `wear` and `videoFollow.ts`).
*/
import { Assets, Texture, VideoSource } from 'pixi.js';
import { parseVideoKey, type VideoPlay } from '../core/model/video';
import type { PropertyBag } from '../core/model/Properties';
import type { TextureRef } from '../core/model/texture';
import {
drivenTimeOf,
drivenVideoKey,
isDrivenVideoKey,
isVideoKey,
parseVideoKey,
type VideoPlay,
} from '../core/model/video';
import { videoDrivenOf, videoTimeOf } from '../core/properties/videos';
import { FileLibrary } from './FileLibrary';
import { soundLibrary } from './SoundLibrary';
import { followVideo, readSpeed, type SpeedReading } from './videoFollow';
const VIDEO_BASE = '/game-maker/data/videos';
/** How close to its end a clip counts as there: half a frame at 30 fps. */
const END_SLACK = 1 / 60;
/** A player nothing has asked for in this long is let go: no sprite wears it any more. */
const IDLE_MS = 2000;
/** Closer than this a seek that waited for the one before it is not worth another. */
const SEEK_SLACK = 1 / 120;
/** What the wearer of a driven player (#404) asks of it, see `wear`. */
interface Drive {
/** Seconds into the clip. */
time: number;
/** Whether the wearer lets it be heard, when it plays at about 1x. */
heard: boolean;
reading: SpeedReading | null;
/** The newest seek asked for while one was still running: only that one follows. */
wanted: number | null;
}
interface Player {
play: VideoPlay;
@@ -28,6 +55,8 @@ interface Player {
gone: boolean;
/** When a sprite last asked for its picture, by `performance.now()`. */
asked: number;
/** Set when its wearer sets the time rather than the video itself. */
drive: Drive | null;
}
export class VideoLibrary {
@@ -60,11 +89,30 @@ export class VideoLibrary {
return player.texture;
}
/** A run begins: every player from its start, heard. */
/**
* What a sprite wears (#404): with its clock driven, a player of its own that shows the picture
* at its Video time; otherwise the ref as it is. `wearer` names the sprite where it is drawn, so
* the same object in two views drives two players. Asked every frame, like `get`.
*/
wear(ref: TextureRef, props: PropertyBag, wearer: string, heard: boolean): TextureRef {
if (!videoDrivenOf(props) || !isVideoKey(ref.file)) return ref;
const key = drivenVideoKey(ref.file, wearer);
const time = videoTimeOf(props);
const player = this.players.get(key) ?? this.start(key, time);
if (!player?.drive) return ref;
const drive = player.drive;
drive.time = time;
drive.heard = heard;
drive.reading = readSpeed(drive.reading, time, performance.now());
if (player.texture) this.follow(player, drive);
return { ...ref, file: key };
}
/** A run begins: every player from its start, heard. A driven one goes where its wearer says. */
startRun(): void {
this.heard = true;
this.paused = false;
for (const player of this.players.values()) {
for (const player of this.freePlayers()) {
this.hear(player);
this.rewind(player);
}
@@ -74,7 +122,7 @@ export class VideoLibrary {
endRun(): void {
this.heard = false;
this.paused = false;
for (const player of this.players.values()) {
for (const player of this.freePlayers()) {
player.element.muted = true;
this.rewind(player);
}
@@ -82,11 +130,41 @@ export class VideoLibrary {
setPaused(paused: boolean): void {
this.paused = paused;
for (const player of this.players.values())
for (const player of this.freePlayers())
if (paused) player.element.pause();
else void player.element.play().catch(() => {});
}
private freePlayers(): Player[] {
return [...this.players.values()].filter((player) => !player.drive);
}
private follow(player: Player, drive: Drive): void {
const { element, play } = player;
const step = followVideo({
target: drivenTimeOf(play, drive.time, element.duration),
speed: drive.reading?.speed ?? 0,
current: element.currentTime,
end: play.end,
});
if (step.seek !== null) this.seek(element, drive, step.seek);
if (step.kind === 'hold') {
element.muted = true;
if (!element.paused) element.pause();
return;
}
if (Math.abs(element.playbackRate - step.rate) > 0.001) element.playbackRate = step.rate;
if (step.heard && drive.heard) this.hear(player);
else element.muted = true;
if (element.paused) void element.play().catch(() => {});
}
/** Seeks, or, while a seek is still running, remembers the newest wish for when it is done. */
private seek(element: HTMLVideoElement, drive: Drive, time: number): void {
if (element.seeking) drive.wanted = time;
else element.currentTime = time;
}
/** Lets every player go: a session that ends takes its videos along. */
clear(): void {
for (const player of this.players.values()) this.drop(player);
@@ -116,40 +194,71 @@ export class VideoLibrary {
}
/** What each player is doing (test hook). */
debugInfo(): { key: string; time: number; paused: boolean; muted: boolean; ready: boolean }[] {
debugInfo(): {
key: string;
time: number;
paused: boolean;
muted: boolean;
ready: boolean;
rate: number;
seeking: boolean;
}[] {
return [...this.players].map(([key, { element, texture }]) => ({
key,
time: element.currentTime,
paused: element.paused,
muted: element.muted,
ready: texture !== null,
rate: element.playbackRate,
seeking: element.seeking,
}));
}
private start(key: string): Player | null {
/** A driven player (#404) opens at the picture its wearer asks for, `time` seconds into the clip. */
private start(key: string, time = 0): Player | null {
const play = parseVideoKey(key);
if (!play) return null;
const drive: Drive | null = isDrivenVideoKey(key)
? { time, heard: false, reading: null, wanted: null }
: null;
const at = drive ? drivenTimeOf(play, time, NaN) : play.start;
const player: Player = {
play,
element: openVideo(`${VIDEO_BASE}/${play.file}`, play.start, (texture) => {
element: openVideo(`${VIDEO_BASE}/${play.file}`, at, (texture) => {
if (player.gone) return texture.destroy(true);
player.texture = texture;
if (drive) this.follow(player, drive);
// Started even in a paused run, and held once there is a picture.
if (this.paused) player.element.pause();
else if (this.paused) player.element.pause();
this.notify();
}),
texture: null,
gain: null,
gone: false,
asked: performance.now(),
drive,
};
this.players.set(key, player);
this.sweeper ??= setInterval(() => this.sweep(), IDLE_MS);
this.watch(player);
if (this.heard) this.hear(player);
if (drive) this.chase(player, drive);
else {
this.watch(player);
if (this.heard) this.hear(player);
}
return player;
}
/** Once a seek is done, the newest one asked for meanwhile follows. */
private chase(player: Player, drive: Drive): void {
const { element } = player;
element.addEventListener('seeked', () => {
const wanted = drive.wanted;
drive.wanted = null;
if (!player.gone && wanted !== null && Math.abs(wanted - element.currentTime) > SEEK_SLACK)
element.currentTime = wanted;
});
}
/**
* Sends a clip back to its start at its end, or holds it there when it does not repeat. A clip
* that runs to the end of the video is sent back when the video ends; the whole video loops by