diff --git a/README.md b/README.md index 7f96bad6..3fc5d9b1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1999 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2000 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 67063270..9cc2fe9f 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -243,6 +243,44 @@ export class SetAnimationKeyCommand extends TimelineCommand { } } +/** + * Keys a number row straight from one value to another (#407): both keys set, the first one linear, + * the keys between them gone – what a driven video needs to run at 1× without a bend. + */ +export class KeyStraightRunCommand extends TimelineCommand { + readonly label = 'Key video at 1×'; + /** How many keys between the two were taken out. */ + removed = 0; + + constructor( + assetId: string, + private readonly trackId: string, + private readonly from: AnimationKey, + private readonly to: AnimationKey, + ) { + super(assetId); + } + + protected apply(project: Project, asset: AnimationAsset): void { + const track = requireTrack(asset, this.trackId); + requireFrame(this.from.frame, 'A key sits on a whole frame from 0'); + requireFrame(this.to.frame, 'A key sits on a whole frame from 0'); + if (this.to.frame <= this.from.frame) throw new CommandError('The run has to end after it starts'); + const def = trackDef(project, asset, track); + if (def?.kind !== 'number') throw new CommandError('Only a number row can run straight'); + const kept = track.keys.filter((k) => k.frame < this.from.frame || k.frame > this.to.frame); + const end = track.keys.find((k) => k.frame === this.to.frame); + this.removed = track.keys.filter((k) => k.frame > this.from.frame && k.frame < this.to.frame).length; + const last: AnimationKey = { frame: this.to.frame, value: coerceValue(def, this.to.value) }; + if (end?.interpolation) last.interpolation = end.interpolation; + track.keys = normalizeKeys([ + ...kept, + { frame: this.from.frame, value: coerceValue(def, this.from.value) }, + last, + ]); + } +} + /** * Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row * that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused. diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 8f92e377..a9d440ce 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -23,6 +23,7 @@ import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; import { objectName, type SceneObject } from './SceneObject'; +import { findVideo, type VideoRef } from './video'; import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ @@ -522,6 +523,26 @@ export function trackDef( return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property); } +/** + * The video the object a row aims at wears at `frame`, for a row shown only with one (#407): what + * the texture row the def names holds there – keyed in this animation, or else on the object. + */ +export function rowVideoAt( + project: Project, + animation: AnimationAsset, + track: AnimationTrack, + frame: number, +): VideoRef | null { + const aimed = aimedAt(project, animation, track.target); + const def = + aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property); + const textureKey = def && 'shownForVideo' in def ? def.shownForVideo : undefined; + if (!aimed || !textureKey) return null; + const keyed = animation.tracks.find((t) => t.target === track.target && t.property === textureKey); + const value = (keyed && trackValueAt(keyed.keys, 'texture', frame)) ?? aimed.bag[textureKey]; + return findVideo(project, textOf(value ?? '')); +} + /** * The rows the dialog offers for the object `target` names: what it can animate, less a behavior's * private variables (#270) and what another row animates already. A row on a private variable keeps diff --git a/src/core/model/video.ts b/src/core/model/video.ts index 39b70bd8..a7fae6b2 100644 --- a/src/core/model/video.ts +++ b/src/core/model/video.ts @@ -72,6 +72,31 @@ export function videoPlayOf(ref: VideoRef): VideoPlay { }; } +/** Seconds a video or clip lasts; 0 while the video's length is not known yet. */ +export function videoLengthOf(ref: VideoRef): number { + const play = videoPlayOf(ref); + const end = Number.isFinite(play.end) ? play.end : ref.asset.duration; + return end > play.start ? end - play.start : 0; +} + +/** A key of a `Video time` row: seconds into the clip on a frame of the animation. */ +export interface FrameValue { + frame: number; + value: number; +} + +/** + * Where a driven video playing at 1× from `value` seconds on `frame` reaches the end of a clip + * `length` seconds long (#407): the last whole frame at `fps` before it, and the clip's time there. + * Null when not one frame fits. + */ +export function realTimeEnd(frame: number, value: number, length: number, fps: number): FrameValue | null { + if (!(length > 0) || !(fps > 0) || !Number.isFinite(value)) return null; + const frames = Math.floor((length - value) * fps + 1e-6); + if (frames < 1) return null; + return { frame: frame + frames, value: Math.round((value + frames / fps) * 1e6) / 1e6 }; +} + const PREFIX = 'video:'; /** diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 54b20140..850ad831 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -8,6 +8,7 @@ import { import { clipboardKeyCount, copyAnimationKeys, + KeyStraightRunCommand, MoveAnimationKeysCommand, PasteAnimationKeysCommand, RemoveAnimationTracksCommand, @@ -23,16 +24,21 @@ import { hasKey, interpolationOf, type KeyRef, + rowVideoAt, sameKey, trackDef, trackLabel, + trackValueAt, } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; +import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video'; +import { VIDEO_TIME_KEY } from '../../core/properties/videos'; import { drawBand, Menu, + type MenuEntry, pressCount, type Size, Theme, @@ -306,6 +312,42 @@ export class TimelineCanvas extends Widget { ); } + // --- a driven video at 1× (#407) ----------------------------------------------------------- + + /** The two keys that play the row's video at 1× from the playhead to the clip's end, or null. */ + videoRun(trackId: string): { from: FrameValue; to: FrameValue; length: number } | null { + const asset = this.asset(); + const track = asset?.tracks.find((t) => t.id === trackId); + if (!asset || !track || track.relative || trackDef(this.state.project, asset, track)?.kind !== 'number') + return null; + const at = this.playheadFrame; + const video = rowVideoAt(this.state.project, asset, track, at); + if (!video) return null; + const value = Number(trackValueAt(track.keys, 'number', at) ?? 0); + const length = videoLengthOf(video); + const to = realTimeEnd(at, value, length, fpsOf(asset)); + return to && { from: { frame: at, value }, to, length }; + } + + keyVideoRun(trackId: string): boolean { + const asset = this.asset(); + const run = this.videoRun(trackId); + if (!asset || !run) return false; + const command = new KeyStraightRunCommand(asset.id, trackId, run.from, run.to); + if (!this.state.execute(command)) return false; + this.state.select(trackSelection(asset.id, [{ trackId, frame: run.to.frame }], trackId)); + const seconds = (s: number) => `${Number(s.toFixed(3))} s`; + const gone = + command.removed === 0 + ? '' + : `, ${command.removed} ${command.removed === 1 ? 'key' : 'keys'} between removed`; + this.state.setNotice( + `Video at 1×: frame ${run.to.frame} shows ${seconds(run.to.value)} of ${seconds(run.length)}${gone}.`, + 'quiet', + ); + return true; + } + /** * A right-click on a key picks it first, the way a node's menu does, so Copy speaks about it; one * on a row's header picks the row. Delete row always means the row under the pointer. @@ -322,6 +364,18 @@ export class TimelineCanvas extends Widget { this.state.select(trackSelection(asset.id, [], track.id)); const picked = this.state.selectedKeys.length; const at = this.playheadFrame; + const timeRow = track ?? asset.tracks.find((t) => t.id === this.state.selectedTrackId); + const videoRun: MenuEntry[] = + timeRow?.property === VIDEO_TIME_KEY + ? [ + { separator: true }, + { + label: `Video at 1× to its end from frame ${at}`, + disabled: this.videoRun(timeRow.id) === null, + onSelect: () => this.keyVideoRun(timeRow.id), + }, + ] + : []; new Menu([ { label: picked > 1 ? `Copy ${picked} keys` : 'Copy key', @@ -336,6 +390,7 @@ export class TimelineCanvas extends Widget { { separator: true }, { label: `Insert frames at ${at}…`, onSelect: () => this.openFramesDialog('insert') }, { label: `Remove frames from ${at}…`, danger: true, onSelect: () => this.openFramesDialog('remove') }, + ...videoRun, { separator: true }, { label: 'Delete row', diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index b979d508..95a643eb 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -20,7 +20,8 @@ export const HEADER_WIDTH = 220; /** Height of the frame ruler above the rows. */ export const RULER_HEIGHT = 22; export const ROW_HEIGHT = 26; -export const MIN_CELL_WIDTH = 4; +/** Far enough out that an hour of video at 30 fps fits a screen (#407): cells then share a pixel. */ +export const MIN_CELL_WIDTH = 0.02; export const MAX_CELL_WIDTH = 48; const DEFAULT_CELL_WIDTH = 16; /** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */ @@ -80,10 +81,12 @@ export function clampPan(view: TimelineView): TimelineView { return view.panX > 0 ? { ...view, panX: 0 } : view; } -/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */ +/** Every `n`-th frame gets a number on the ruler; a round step (1, 2, 5, 10, …) so the numbers read like a scale. */ export function rulerStep(view: TimelineView): number { - const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; - return steps.find((step) => step * view.cellWidth >= RULER_LABEL_MIN_PX) ?? 1000; + if (!(view.cellWidth > 0)) return 1; + for (let decade = 1; ; decade *= 10) + for (const step of [decade, 2 * decade, 5 * decade]) + if (step * view.cellWidth >= RULER_LABEL_MIN_PX) return step; } /** diff --git a/tests/core/video-clock.test.ts b/tests/core/video-clock.test.ts index 46eeb277..3a9b0641 100644 --- a/tests/core/video-clock.test.ts +++ b/tests/core/video-clock.test.ts @@ -3,10 +3,27 @@ * at its Video time, a number an animation keys like any other. */ import { describe, expect, it } from 'vitest'; -import { CreateVideoCommand } from '../../src/core/commands/assetCommands'; +import { + AddAnimationTrackCommand, + KeyStraightRunCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, + SetKeyInterpolationCommand, +} from '../../src/core/commands/animationCommands'; +import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands'; import { AddClipCommand } from '../../src/core/commands/clipCommands'; -import { animatableDefs } from '../../src/core/model/animation'; -import { drivenTimeOf, drivenVideoKey, isDrivenVideoKey, parseVideoKey } from '../../src/core/model/video'; +import { CommandError } from '../../src/core/commands/Command'; +import { animatableDefs, rowVideoAt } from '../../src/core/model/animation'; +import { findAssetOf } from '../../src/core/model/Project'; +import { + drivenTimeOf, + drivenVideoKey, + findVideo, + isDrivenVideoKey, + parseVideoKey, + realTimeEnd, + videoLengthOf, +} from '../../src/core/model/video'; import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema'; import { VIDEO_CLOCK_KEY, @@ -92,3 +109,92 @@ describe('the Video clock and Video time rows', () => { expect(keys).toContain(VIDEO_CLOCK_KEY); }); }); + +describe('keying a driven video at 1× to its end (#407)', () => { + it('ends on the last whole frame before the clip does', () => { + expect(realTimeEnd(0, 0, 57.27, 10)).toEqual({ frame: 572, value: 57.2 }); + expect(realTimeEnd(5, 0.5, 2, 10)).toEqual({ frame: 20, value: 2 }); + expect(realTimeEnd(0, 1.2, 2, 30)).toEqual({ frame: 24, value: 2 }); + }); + + it('has nothing to key when not one frame fits or nothing is known', () => { + expect(realTimeEnd(0, 1.95, 2, 10)).toBeNull(); + expect(realTimeEnd(0, 0, 0, 10)).toBeNull(); + expect(realTimeEnd(0, 0, 2, 0)).toBeNull(); + }); + + function setup() { + const f = makeProject(); + const video = new CreateVideoCommand('Intro', FILE, { + width: 320, + height: 180, + duration: 4, + fps: 30, + audio: false, + }); + f.stack.execute(video); + const clip = new AddClipCommand(video.asset.id, { [VIDEO_START_KEY]: 0.5, [VIDEO_END_KEY]: 2 }); + f.stack.execute(clip); + const sprite = new CreateAssetCommand('sprite', 'Shot', { + texture: clip.clipId, + [VIDEO_CLOCK_KEY]: 'driven', + }); + f.stack.execute(sprite); + const cut = new CreateAssetCommand('animation', 'Cut', { fps: 10 }); + f.stack.execute(cut); + f.stack.execute(new SetAnimationRootCommand(cut.asset.id, sprite.asset.id)); + const row = new AddAnimationTrackCommand(cut.asset.id, '', VIDEO_TIME_KEY); + f.stack.execute(row); + const animation = () => findAssetOf(f.project, cut.asset.id, 'animation')!; + return { ...f, videoId: video.asset.id, clipId: clip.clipId, animation, rowId: row.trackId }; + } + + it('measures a clip by its stretch and the whole video by its file', () => { + const { project, videoId, clipId } = setup(); + expect(videoLengthOf(findVideo(project, clipId)!)).toBe(1.5); + expect(videoLengthOf(findVideo(project, videoId)!)).toBe(4); + }); + + it('finds the video the row wears, also where the texture is keyed in the same animation', () => { + const { project, stack, animation, rowId, videoId, clipId } = setup(); + const row = () => animation().tracks.find((t) => t.id === rowId)!; + expect(rowVideoAt(project, animation(), row(), 0)?.clip?.id).toBe(clipId); + const texture = new AddAnimationTrackCommand(animation().id, '', 'texture'); + stack.execute(texture); + stack.execute(new SetAnimationKeyCommand(animation().id, texture.trackId, 0, videoId)); + stack.execute(new SetAnimationKeyCommand(animation().id, texture.trackId, 10, clipId)); + expect(rowVideoAt(project, animation(), row(), 5)?.clip).toBeNull(); + expect(rowVideoAt(project, animation(), row(), 12)?.clip?.id).toBe(clipId); + }); + + it('sets both keys, makes the first linear and takes the keys between out, in one undo step', () => { + const { stack, animation, rowId } = setup(); + const id = animation().id; + stack.execute(new SetAnimationKeyCommand(id, rowId, 5, 0.3)); + stack.execute(new SetKeyInterpolationCommand(id, [{ trackId: rowId, frame: 5 }], 'step')); + stack.execute(new SetAnimationKeyCommand(id, rowId, 9, 1)); + stack.execute(new SetAnimationKeyCommand(id, rowId, 30, 9)); + const before = structuredClone(animation().tracks); + const run = new KeyStraightRunCommand(id, rowId, { frame: 5, value: 0.3 }, { frame: 17, value: 1.5 }); + stack.execute(run); + expect(run.removed).toBe(1); + expect(animation().tracks[0]!.keys).toEqual([ + { frame: 5, value: 0.3 }, + { frame: 17, value: 1.5 }, + { frame: 30, value: 9 }, + ]); + stack.undo(); + expect(animation().tracks).toEqual(before); + }); + + it('refuses a run that does not go forward or a row that is no number', () => { + const { stack, animation, rowId } = setup(); + const id = animation().id; + const clock = new AddAnimationTrackCommand(id, '', VIDEO_CLOCK_KEY); + stack.execute(clock); + const run = (track: string, to: number) => + new KeyStraightRunCommand(id, track, { frame: 4, value: 0 }, { frame: to, value: 1 }); + expect(() => stack.execute(run(rowId, 4))).toThrow(CommandError); + expect(() => stack.execute(run(clock.trackId, 10))).toThrow(CommandError); + }); +}); diff --git a/tests/editor/timeline-view.test.ts b/tests/editor/timeline-view.test.ts index d1e37fc1..5c0cf9e1 100644 --- a/tests/editor/timeline-view.test.ts +++ b/tests/editor/timeline-view.test.ts @@ -70,6 +70,11 @@ describe('timeline geometry', () => { expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH); }); + it('zooms out far enough that an hour of video at 30 fps fits a screen (#407)', () => { + const out = zoomCells(DEFAULT_VIEW, 1e-6, 0); + expect(frameToX(out, 3600 * 30)).toBeLessThanOrEqual(2200); + }); + it('does not let frame 0 drift right of the left edge', () => { expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0); expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30); @@ -79,6 +84,8 @@ describe('timeline geometry', () => { expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1); expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5); expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10); + expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500); + expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000); }); }); diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index fabaf39d..9f15b886 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -353,6 +353,34 @@ export async function video(t) { along.every((p) => !p.paused && !p.muted && Math.abs(p.rate - 1) <= 0.1 && gap(p.time, p.asked) < 0.15), JSON.stringify(along), ); + + // 66l. Right-click → "Video at 1× to its end" (#407): from the playhead (frame 5, 0.5 s) the + // last whole frame before the clip ends, the key on 9 between them gone; one undo step. + const rowKeys = () => + json( + `__gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys.map((k) => [k.frame, k.value])`, + ); + await mutate(` + __gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys = + [{ frame: 0, value: 0 }, { frame: 5, value: 0.5 }, { frame: 9, value: 1.7 }];`); + const keyed = JSON.stringify(await rowKeys()); + await t.evaluate('__gm.animation.scrub(5)'); + const inRow = await json("__gm.animation.cellAt('vd66-row', 2)"); + await click(inRow.x, inRow.y, 'right'); + const runItem = await t.find('Video at 1× to its end from frame 5', true); + await click(runItem.x, runItem.y); + const steps = Math.floor((uploaded.duration - 0.5) * 10 + 1e-6); + const ran = await rowKeys(); + await t.undo(); + check( + 'the menu keys the video at 1x from the playhead to the last frame before its end, in one undo step', + ran.length === 3 && + JSON.stringify(ran.slice(0, 2)) === '[[0,0],[5,0.5]]' && + ran[2][0] === 5 + steps && + Math.abs(ran[2][1] - (0.5 + steps / 10)) < 1e-6 && + JSON.stringify(await rowKeys()) === keyed, + JSON.stringify({ ran, steps, duration: uploaded.duration, undone: await rowKeys() }), + ); await showTab('scene'); await mutate(`