Let an animation drive a sprite's video picture by picture (#404)
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:
1 parent
a62d1d0faf
commit
c9e8a3f6ac
24 files changed
+659
-41
No files matched your search
+37
-1
@@ -84,22 +84,58 @@ export function videoKey(play: VideoPlay): string {
|
||||
return `${PREFIX}${play.file}#${play.start}~${end}~${play.loop ? 1 : 0}~${play.volume}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key of a player one wearer drives on its own (#404): two sprites driving the same clip must
|
||||
* not fight over one `<video>`, so the wearer's name goes after an `@`.
|
||||
*/
|
||||
export function drivenVideoKey(key: string, wearer: string): string {
|
||||
return `${key}@${wearer}`;
|
||||
}
|
||||
|
||||
export function isVideoKey(key: string): boolean {
|
||||
return parseVideoKey(key) !== null;
|
||||
}
|
||||
|
||||
/** Whether the key names a player its wearer drives, see `drivenVideoKey`. */
|
||||
export function isDrivenVideoKey(key: string): boolean {
|
||||
return isVideoKey(key) && key.includes('@');
|
||||
}
|
||||
|
||||
export function parseVideoKey(key: string): VideoPlay | null {
|
||||
if (!key.startsWith(PREFIX)) return null;
|
||||
const hash = key.indexOf('#');
|
||||
if (hash < 0) return null;
|
||||
const file = key.slice(PREFIX.length, hash);
|
||||
const parts = key.slice(hash + 1).split('~');
|
||||
const at = key.indexOf('@', hash);
|
||||
const parts = key.slice(hash + 1, at < 0 ? undefined : at).split('~');
|
||||
const [start, end, loop, volume] = parts.map(Number);
|
||||
if (!isVideoFile(file) || parts.length !== 4 || ![start, end, loop, volume].every(Number.isFinite))
|
||||
return null;
|
||||
return { file, start: start!, end: end! > 0 ? end! : Infinity, loop: loop === 1, volume: volume! };
|
||||
}
|
||||
|
||||
/** How far before its end a held clip shows its last picture: the end itself is the next one's. */
|
||||
const LAST_PICTURE = 0.001;
|
||||
|
||||
/**
|
||||
* Where in its file a driven video stands at `time` seconds into its clip (#404). Before the start
|
||||
* it shows the first picture, past the end it goes round with Loop and holds the last one without.
|
||||
* `duration` is the file's, which a clip running to the end of the video needs; unknown (NaN, not
|
||||
* loaded yet) the time is not bounded at the end.
|
||||
*/
|
||||
export function drivenTimeOf(
|
||||
play: Pick<VideoPlay, 'start' | 'end' | 'loop'>,
|
||||
time: number,
|
||||
duration: number,
|
||||
): number {
|
||||
const end = Number.isFinite(play.end) ? play.end : duration;
|
||||
const length = end - play.start;
|
||||
const run = Math.max(0, Number.isFinite(time) ? time : 0);
|
||||
if (!(length > 0)) return play.start + run;
|
||||
if (play.loop) return play.start + (run % length);
|
||||
return play.start + Math.min(run, Math.max(0, length - LAST_PICTURE));
|
||||
}
|
||||
|
||||
/** Where in its file a player is after `elapsed` seconds of playing (#402). */
|
||||
export function videoTimeAt(play: Pick<VideoPlay, 'start' | 'end' | 'loop'>, elapsed: number): number {
|
||||
const length = play.end - play.start;
|
||||
|
||||
@@ -129,7 +129,8 @@ import {
|
||||
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
|
||||
import { ASSET_FX_KEY } from './assetFx';
|
||||
import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses';
|
||||
import { VIDEO_ROWS } from './videos';
|
||||
import { SPRITE_VIDEO_ROWS, VIDEO_ROWS } from './videos';
|
||||
import { findVideo } from '../model/video';
|
||||
import { Theme } from '../../ui';
|
||||
|
||||
/**
|
||||
@@ -166,7 +167,8 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
* (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the
|
||||
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a
|
||||
* list of such choices must all hold. `shownWhenSet` names a text or asset row the row belongs to:
|
||||
* shown while that one is not empty (#170).
|
||||
* shown while that one is not empty (#170). `shownForVideo` names a texture row: shown while it
|
||||
* holds a video or a clip of one (#404), which only a check that knows the project can tell.
|
||||
*/
|
||||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
[P in Exclude<K, 'object'>]: {
|
||||
@@ -179,6 +181,7 @@ export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
shownWith?: string;
|
||||
shownFor?: ShownFor | readonly ShownFor[];
|
||||
shownWhenSet?: string;
|
||||
shownForVideo?: string;
|
||||
} & Extras<P>;
|
||||
}[Exclude<K, 'object'>];
|
||||
|
||||
@@ -193,8 +196,10 @@ interface ShownFor {
|
||||
}
|
||||
|
||||
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
||||
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
||||
export function isRowShown(def: PropertyDef, values: PropertyBag, project?: Project): boolean {
|
||||
if ('noRow' in def && def.noRow) return false;
|
||||
if ('shownForVideo' in def && def.shownForVideo && project)
|
||||
if (!findVideo(project, textOf(values[def.shownForVideo] ?? ''))) return false;
|
||||
const choices: readonly ShownFor[] =
|
||||
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : [];
|
||||
if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false;
|
||||
@@ -532,6 +537,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
// A video or a clip of one plays on the sprite (#402).
|
||||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '', moving: true },
|
||||
...SPRITE_VIDEO_ROWS,
|
||||
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
|
||||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||||
],
|
||||
|
||||
@@ -15,6 +15,40 @@ export const VIDEO_ROWS: readonly PropertyDef[] = [
|
||||
{ key: VIDEO_LOOP_KEY, label: 'Loop', kind: 'boolean', default: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Who sets the time of the video a sprite wears (#404): `free` lets it play by itself, `driven`
|
||||
* shows the picture at `videoTime`, seconds into the clip, which an animation keys like any number.
|
||||
*/
|
||||
export const VIDEO_CLOCK_KEY = 'videoClock';
|
||||
export const VIDEO_TIME_KEY = 'videoTime';
|
||||
|
||||
export const VIDEO_CLOCK_OPTIONS = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'driven', label: 'Driven' },
|
||||
];
|
||||
|
||||
/** On a sprite, below its texture; shown only while the texture is a video or a clip of one. */
|
||||
export const SPRITE_VIDEO_ROWS: readonly PropertyDef[] = [
|
||||
{
|
||||
key: VIDEO_CLOCK_KEY,
|
||||
label: 'Video clock',
|
||||
kind: 'enum',
|
||||
default: 'free',
|
||||
options: VIDEO_CLOCK_OPTIONS,
|
||||
shownForVideo: 'texture',
|
||||
},
|
||||
{
|
||||
key: VIDEO_TIME_KEY,
|
||||
label: 'Video time',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
step: 0.01,
|
||||
decimals: 3,
|
||||
shownFor: { key: VIDEO_CLOCK_KEY, values: ['driven'] },
|
||||
shownForVideo: 'texture',
|
||||
},
|
||||
];
|
||||
|
||||
/** The rows of a clip of a video, set in the Video tab. */
|
||||
export const VIDEO_CLIP_ROWS: readonly PropertyDef[] = [
|
||||
{ key: VIDEO_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
@@ -29,6 +63,14 @@ export function videoLoopsOf(bag: PropertyBag): boolean {
|
||||
return bag[VIDEO_LOOP_KEY] === undefined ? true : booleanOf(bag[VIDEO_LOOP_KEY]);
|
||||
}
|
||||
|
||||
export function videoDrivenOf(bag: PropertyBag): boolean {
|
||||
return bag[VIDEO_CLOCK_KEY] === 'driven';
|
||||
}
|
||||
|
||||
export function videoTimeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VIDEO_TIME_KEY], 'number', 0);
|
||||
}
|
||||
|
||||
/** Start and end of a clip in seconds, `end` 0 for the end of the video. */
|
||||
export function videoSpanOf(bag: PropertyBag): { start: number; end: number } {
|
||||
const seconds = (key: string) => Math.max(0, coerceAs(bag[key], 'number', 0));
|
||||
|
||||
Reference in new issue
Block a user