/** * A video that follows the animation (#404): a sprite whose Video clock is driven shows the picture * at its Video time, a number an animation keys like any other. */ import { describe, expect, it } from 'vitest'; 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 { 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, VIDEO_END_KEY, VIDEO_START_KEY, VIDEO_TIME_KEY, } from '../../src/core/properties/videos'; import { makeProject } from '../helpers'; const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`; const KEY = `video:${FILE}#2~4~1~1`; describe('where a driven video stands', () => { const clip = { start: 2, end: 4, loop: true }; it('counts from the start of its clip', () => { expect(drivenTimeOf(clip, 0.5, 10)).toBe(2.5); }); it('goes round past the end with Loop', () => { expect(drivenTimeOf(clip, 5, 10)).toBe(3); }); it('holds the last picture without Loop, and the first one before the start', () => { expect(drivenTimeOf({ ...clip, loop: false }, 9, 10)).toBe(3.999); expect(drivenTimeOf(clip, -1, 10)).toBe(2); }); it('measures a clip running to the end of the video by the file', () => { const tail = { start: 6, end: Infinity, loop: true }; expect(drivenTimeOf(tail, 5, 10)).toBe(7); expect(drivenTimeOf({ ...tail, loop: false }, 5, 10)).toBe(9.999); expect(drivenTimeOf(tail, 5, NaN)).toBe(11); }); }); describe('the player a wearer drives', () => { it('goes by the clip key and the name of its wearer', () => { const key = drivenVideoKey(KEY, 'view3:obj1'); expect(key).toBe(`${KEY}@view3:obj1`); expect(isDrivenVideoKey(key)).toBe(true); expect(isDrivenVideoKey(KEY)).toBe(false); expect(parseVideoKey(key)).toEqual(parseVideoKey(KEY)); }); }); describe('the Video clock and Video time rows', () => { function setup() { const f = makeProject(); const create = new CreateVideoCommand('Intro', FILE, { width: 320, height: 180, duration: 2, fps: 30, audio: false, }); f.stack.execute(create); const clip = new AddClipCommand(create.asset.id, { [VIDEO_START_KEY]: 0.5, [VIDEO_END_KEY]: 1 }); f.stack.execute(clip); return { project: f.project, videoId: create.asset.id, clipId: clip.clipId }; } const row = (key: string) => schemaFor('sprite').find((d) => d.key === key)!; it('are shown only while the texture is a video or a clip of one', () => { const { project, videoId, clipId } = setup(); const driven = { [VIDEO_CLOCK_KEY]: 'driven' }; for (const texture of [videoId, clipId]) { expect(isRowShown(row(VIDEO_CLOCK_KEY), { texture }, project)).toBe(true); expect(isRowShown(row(VIDEO_TIME_KEY), { texture, ...driven }, project)).toBe(true); } expect(isRowShown(row(VIDEO_CLOCK_KEY), { texture: '' }, project)).toBe(false); expect(isRowShown(row(VIDEO_TIME_KEY), { texture: '', ...driven }, project)).toBe(false); }); it('show the time only while the clock is driven', () => { const { project, videoId } = setup(); expect(isRowShown(row(VIDEO_TIME_KEY), { texture: videoId }, project)).toBe(false); }); it('can be keyed by an animation', () => { const keys = animatableDefs('sprite').map((d) => d.key); expect(keys).toContain(VIDEO_TIME_KEY); 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); }); });