/** * Videos (#402): an asset with clips, worn by a sprite through `texture` like a picture, played * under a key the texture library hands to its player. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateVideoCommand, DuplicateAssetCommand, } from '../../src/core/commands/assetCommands'; import { AddClipCommand, RemoveClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands'; import { CommandError } from '../../src/core/commands/Command'; import type { VideoAsset, VideoClip } from '../../src/core/model/Asset'; import { findAssetOf } from '../../src/core/model/Project'; import { clipsOf } from '../../src/core/model/sound'; import { resolvePicture, resolveTexture } from '../../src/core/model/texture'; import { drivenVideoKey, findVideo, isVideoFile, parseVideoKey, routedVideoKey, posterFileOf, videoKey, videoPlayOf, videoSpan, videoTimeAt, } from '../../src/core/model/video'; import { pictureChoices, textureRefLabel, videoChoices } from '../../src/core/properties/textureRef'; import { compositionBusPick, FROM_ASSET_BUS, MASTER_BUS } from '../../src/core/properties/buses'; import { VIDEO_BUS_KEY, VIDEO_END_KEY, VIDEO_LOOP_KEY, VIDEO_START_KEY, VIDEO_VOLUME_KEY, } from '../../src/core/properties/videos'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { formatDuration, isPending, videoStatusText } from '../../src/editor/video/videoInfo'; import { makeProject } from '../helpers'; const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`; const DETAILS = { width: 1920, height: 1080, duration: 12.5, fps: 29.97, audio: true }; function setup() { const f = makeProject(); const create = new CreateVideoCommand('Intro', FILE, DETAILS); f.stack.execute(create); const clip = new AddClipCommand(create.asset.id, { [VIDEO_START_KEY]: 2, [VIDEO_END_KEY]: 4.5 }); f.stack.execute(clip); const video = () => findAssetOf(f.project, create.asset.id, 'video')!; return { f, video, clipId: clip.clipId }; } describe('a video asset (#402)', () => { it('names an MP4 the worker made, nothing else', () => { expect(isVideoFile(FILE)).toBe(true); expect(isVideoFile(`${'c7'.repeat(32)}.mp4`)).toBe(true); expect(isVideoFile(`a1b2c3d4e5f6/${'c7'.repeat(32)}.mov`)).toBe(false); expect(isVideoFile('../x.mp4')).toBe(false); const f = makeProject(); expect(() => f.stack.execute(new CreateVideoCommand('Bad', 'holiday.mov', DETAILS))).toThrow( CommandError, ); expect(posterFileOf(FILE)).toBe(`a1b2c3d4e5f6/${'c7'.repeat(32)}.jpg`); }); it('keeps its details, loops by default and has clips like a sound', () => { const { video, clipId } = setup(); expect(video()).toMatchObject({ type: 'video', file: FILE, ...DETAILS }); expect(video().props[VIDEO_LOOP_KEY]).toBe(true); expect(clipsOf(video())).toEqual([ { id: clipId, name: 'Clip 1', props: { [VIDEO_START_KEY]: 2, [VIDEO_END_KEY]: 4.5 } }, ]); }); it('takes only the rows a video clip has', () => { const { f, video, clipId } = setup(); expect(() => new SetClipPropsCommand(video().id, clipId, { reverse: true }).execute(f.project)).toThrow( CommandError, ); expect(() => new SetClipPropsCommand(video().id, clipId, { [VIDEO_START_KEY]: -1 }).execute(f.project), ).toThrow(CommandError); f.stack.execute(new SetClipPropsCommand(video().id, clipId, { [VIDEO_END_KEY]: 6 })); expect(clipsOf(video())[0]!.props[VIDEO_END_KEY]).toBe(6); f.stack.undo(); expect(clipsOf(video())[0]!.props[VIDEO_END_KEY]).toBe(4.5); }); it('resolves a clip id to its video', () => { const { f, video, clipId } = setup(); expect(findVideo(f.project, clipId)).toEqual({ asset: video(), clip: clipsOf(video())[0] }); expect(findVideo(f.project, video().id)).toEqual({ asset: video(), clip: null }); expect(findVideo(f.project, 'nothing')).toBeNull(); }); it('survives saving and loading, its clip ids included', () => { const { f, video } = setup(); const back = deserializeProject(serializeProject(f.project)); expect(findAssetOf(back, video().id, 'video')).toEqual(video()); }); it('drops a file that is no video and details that are no numbers when loading', () => { const { f, video } = setup(); const raw = structuredClone(serializeProject(f.project)) as { assets: Record[]; }; const stored = raw.assets.find((a) => a.id === video().id)!; Object.assign(stored, { file: '../etc/passwd', width: 'wide', duration: -3, audio: 'yes' }); const back = findAssetOf(deserializeProject(raw), video().id, 'video')!; expect(back).toMatchObject({ file: '', width: 0, duration: 0, audio: false }); }); it('gives a copy clips of its own', () => { const { f, video, clipId } = setup(); const copy = new DuplicateAssetCommand(video().id); f.stack.execute(copy); const twin = f.project.assets.find((a) => a.type === 'video' && a.id !== video().id) as VideoAsset; expect(clipsOf(twin)).toHaveLength(1); expect(clipsOf(twin)[0]!.id).not.toBe(clipId); }); }); describe('the stretch a video plays', () => { it('runs from the start to the end of the video for an end of 0', () => { expect(videoSpan(10, 0, 0)).toEqual({ start: 0, end: 10 }); expect(videoSpan(10, 2, 4)).toEqual({ start: 2, end: 4 }); }); it('stays inside the video', () => { expect(videoSpan(10, 3, 25)).toEqual({ start: 3, end: 10 }); expect(videoSpan(10, -1, 4)).toEqual({ start: 0, end: 4 }); expect(videoSpan(10, 12, 0).start).toBeLessThan(10); }); it('runs to the end when the end is not after the start', () => { expect(videoSpan(10, 6, 4)).toEqual({ start: 6, end: 10 }); }); it('is told where it stands after a while, repeating or held at its end', () => { const clip = { start: 2, end: 4, loop: true }; expect(videoTimeAt(clip, 0.5)).toBe(2.5); expect(videoTimeAt(clip, 2.5)).toBe(2.5); expect(videoTimeAt({ ...clip, loop: false }, 9)).toBe(4); expect(videoTimeAt({ start: 0, end: Infinity, loop: true }, 7)).toBe(7); }); }); describe('the key a playing video goes by', () => { it('says the file, the stretch, the loop and the volume, and is read back', () => { const { f, video, clipId } = setup(); f.stack.execute(new SetClipPropsCommand(video().id, clipId, {})); video().props[VIDEO_VOLUME_KEY] = 0.5; const play = videoPlayOf({ asset: video(), clip: clipsOf(video())[0]! }); expect(play).toEqual({ file: FILE, start: 2, end: 4.5, loop: true, volume: 0.5 }); expect(parseVideoKey(videoKey(play))).toEqual(play); }); it('names the whole video, or a clip that runs to its end, as running to the end', () => { const { video } = setup(); const whole = videoPlayOf({ asset: video(), clip: null }); expect(whole.end).toBe(Infinity); expect(videoKey(whole)).toBe(`video:${FILE}#0~0~1~1`); expect(parseVideoKey(videoKey(whole))?.end).toBe(Infinity); const tail = { id: 'tail', name: 'Tail', props: { [VIDEO_START_KEY]: 10, [VIDEO_END_KEY]: 0 } }; expect(videoPlayOf({ asset: video(), clip: tail })).toMatchObject({ start: 10, end: Infinity }); }); it('names the bus a sprite picked, read back, so each bus plays in a player of its own (#418)', () => { const { f, video } = setup(); const whole = videoKey(videoPlayOf({ asset: video(), clip: null })); const film = new CreateAssetCommand('bus', 'Film'); const trailer = new CreateAssetCommand('composition', 'Trailer'); f.stack.execute(film); f.stack.execute(trailer); const worn = (pick: string) => resolveTexture(f.project, { texture: video().id, [VIDEO_BUS_KEY]: pick })!.file; expect(worn('')).toBe(whole); expect(worn(FROM_ASSET_BUS)).toBe(whole); expect(worn(MASTER_BUS)).toBe(`${whole}!mix`); expect(worn(film.asset.id)).toBe(`${whole}!mix:${film.asset.id}`); expect(worn('gone')).toBe(`${whole}!mix`); expect(worn(compositionBusPick(trailer.asset.id))).toBe(`${whole}!mix:${trailer.asset.id}`); expect(parseVideoKey(worn(film.asset.id))).toEqual({ ...videoPlayOf({ asset: video(), clip: null }), bus: `mix:${film.asset.id}`, }); expect(parseVideoKey(drivenVideoKey(worn(MASTER_BUS), 'game:p~i'))?.bus).toBe('mix'); expect(routedVideoKey(worn(MASTER_BUS), null)).toBe(whole); }); it('is no key for anything else', () => { expect(parseVideoKey(FILE)).toBeNull(); expect(parseVideoKey('gen:abc@1')).toBeNull(); expect(parseVideoKey(`video:holiday.mov#0~0~1~1`)).toBeNull(); expect(parseVideoKey(`video:${FILE}#0~0~1`)).toBeNull(); expect(parseVideoKey(`video:${FILE}#a~0~1~1`)).toBeNull(); }); }); describe('a video worn by a sprite', () => { it('reads like a picture of its size, under its key', () => { const { f, video, clipId } = setup(); expect(resolvePicture(f.project, video().id)).toEqual({ file: `video:${FILE}#0~0~1~1`, smooth: true, rect: null, sourceWidth: 1920, sourceHeight: 1080, slice: null, }); expect(resolvePicture(f.project, clipId)?.file).toBe(`video:${FILE}#2~4.5~1~1`); }); it('is no picture while it names no file', () => { const f = makeProject(); const raw = structuredClone(serializeProject(f.project)) as { assets: unknown[] }; raw.assets.push({ id: 'v1', type: 'video', name: 'Empty', props: {}, file: '' }); expect(resolvePicture(deserializeProject(raw), 'v1')).toBeNull(); }); it('is offered with its clips where a texture field plays videos, after the pictures', () => { const { f, video, clipId } = setup(); expect(pictureChoices(f.project).some((c) => c.asset.type === 'video')).toBe(false); expect(videoChoices(f.project).map((c) => [c.id, c.label])).toEqual([ [video().id, 'Intro'], [clipId, 'Intro / Clip 1'], ]); expect(textureRefLabel(f.project, clipId)).toBe('Intro / Clip 1'); }); it('reads as missing once its clip is gone, and comes back on undo', () => { const { f, video, clipId } = setup(); f.stack.execute(new RemoveClipCommand(video().id, clipId)); expect(resolvePicture(f.project, clipId)).toBeNull(); expect(textureRefLabel(f.project, clipId)).toBe(`${clipId}?`); f.stack.undo(); expect(resolvePicture(f.project, clipId)).not.toBeNull(); }); }); describe('what the picker says about a video', () => { it('waits, converts, is ready or failed', () => { const base = { file: FILE, name: 'a.mov', bytes: 0 }; expect(videoStatusText({ ...base, status: 'queued' })).toBe('Waiting to be converted…'); expect(videoStatusText({ ...base, status: 'converting', progress: 0.426 })).toBe('Converting… 43 %'); expect(videoStatusText({ ...base, status: 'failed', error: 'no picture' })).toBe( 'Could not be converted: no picture', ); expect(videoStatusText({ ...base, status: 'ready', ...DETAILS })).toBe( '1920 × 1080 · 12.5 s · 29.97 fps · with sound', ); expect([isPending({ ...base, status: 'converting' }), isPending({ ...base, status: 'failed' })]).toEqual([ true, false, ]); }); it('says what to do with an upload kept without converting it, which waits for nobody', () => { const kept = { file: FILE, name: 'a.mkv', bytes: 10, status: 'unconverted' as const }; expect(videoStatusText(kept)).toBe('Not converted – Convert makes it usable'); expect(isPending(kept)).toBe(false); }); it('writes a length as long as it needs', () => { expect([formatDuration(4), formatDuration(59.94), formatDuration(65), formatDuration(3723)]).toEqual([ '4 s', '59.9 s', '1:05', '1:02:03', ]); }); });