// App previews / promo video: clip planning, ffmpeg argv, poster time codes, ffprobe parsing and checks, recording argv, // Apple preview sets and Play's promo video URL. Pure: no ffmpeg, simulator, emulator or store needed. import { describe, expect, test } from 'bun:test'; import { setUpToDate } from '../cli/apple/asc'; import { mimeType, previewAttrs, previewFiles, previewSets } from '../cli/apple/previews'; import { type ShipConfig, validateConfig } from '../cli/lib/config'; import { listingBody } from '../cli/play/listing'; import { adbPullArgs, adbRecordArgs, adbRmArgs, captureShortfall, clipName, concatArgs, concatList, RECORD_MAX_SIDE, recordSize, simRecordArgs, trimArgs, validateRecord } from '../cli/shots/devices'; import { clipsToRecord } from '../cli/shots/record'; import { checkVideo, parseProbe, type ProbeInfo } from '../cli/shots/spec'; import { clipFiles, defaultTargets, planClips, posterArgs, timeCode, videoArgs } from '../cli/shots/video'; describe('clip planning', () => { test('fills the target length in order and cuts the last clip', () => { const { plan, total } = planClips([{ file: 'a', dur: 8 }, { file: 'b', dur: 10 }, { file: 'c', dur: 12 }], 25); expect(plan).toEqual([{ file: 'a', dur: 8, start: 0 }, { file: 'b', dur: 10, start: 8 }, { file: 'c', dur: 7, start: 18 }]); expect(total).toBe(25); }); test('clamps to 15-30 s and rejects too little footage', () => { expect(planClips([{ file: 'a', dur: 60 }], 90).total).toBe(30); expect(planClips([{ file: 'a', dur: 60 }], 5).total).toBe(15); expect(() => planClips([{ file: 'a', dur: 6 }, { file: 'b', dur: 6 }], 25)).toThrow('at least 15 s'); }); test('skips slivers under half a second', () => { const { plan, total } = planClips([{ file: 'a', dur: 14.7 }, { file: 'b', dur: 0.2 }, { file: 'c', dur: 5 }], 15); expect(plan.map(p => p.file)).toEqual(['a', 'c']); expect(total).toBe(15); }); test('clip files: per language first, then shared', () => { const fs: Record = { 'clips/en': ['02-b.mp4', '01-a.webm', 'notes.txt'], clips: ['x.mov'] }; const list = (d: string) => fs[d] ?? []; expect(clipFiles('clips', 'en', list)).toEqual(['clips/en/01-a.webm', 'clips/en/02-b.mp4']); expect(clipFiles('clips', 'tr', list)).toEqual(['clips/x.mov']); }); }); describe('ffmpeg argv', () => { const plan = [{ file: 'a.mp4', dur: 10, start: 0 }, { file: 'b.mp4', dur: 8, start: 10 }]; test('preview: scale/crop, concat, captions, silent stereo track, H.264 High 4.0 + AAC 256k', () => { const a = videoArgs({ plan, total: 18, w: 886, h: 1920, captions: ['cap0.png', null], out: 'o.mp4' }); const fc = a[a.indexOf('-filter_complex') + 1]!; expect(a.filter(x => x === '-i').length).toBe(4); expect(a).toContain('anullsrc=channel_layout=stereo:sample_rate=48000'); expect(fc).toContain('scale=886:1920:force_original_aspect_ratio=increase,crop=886:1920'); expect(fc).toContain('concat=n=2:v=1:a=0[base]'); expect(fc).toContain("[base][2:v]overlay=0:0:enable='between(t,0.3,9.75)'[c0]"); expect(fc).toContain('[3:a]atrim=0:18'); for (const x of ['libx264', 'high', '4.0', 'aac', '256k', '48000', '+faststart']) expect(a).toContain(x); expect(a.at(-1)).toBe('o.mp4'); }); test('contain + music: pad on the background, looped audio with a fade', () => { const a = videoArgs({ plan, total: 18, w: 1200, h: 1600, fit: 'contain', background: '#101010', audio: 'm.mp3', out: 'o.mp4' }); const fc = a[a.indexOf('-filter_complex') + 1]!; expect(fc).toContain('pad=1200:1600:(ow-iw)/2:(oh-ih)/2:color=#101010'); expect(a.join(' ')).toContain('-stream_loop -1 -i m.mp3'); expect(fc).toContain('afade=t=out:st=16.5:d=1.5'); }); test('poster and trim', () => { expect(posterArgs('v.mp4', 5, 'p.png')).toEqual(['-v', 'error', '-y', '-ss', '5', '-i', 'v.mp4', '-frames:v', '1', '-pix_fmt', 'rgb24', 'p.png']); const t = trimArgs('raw.webm', 'out.mp4', 1.25, 6); expect(t.slice(0, 9)).toEqual(['-v', 'error', '-y', '-ss', '1.250', '-i', 'raw.webm', '-t', '6.000']); expect(t).toContain('-an'); expect(t.join(' ')).toContain('tpad=stop_mode=clone:stop_duration=6.000,fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p'); expect(trimArgs('r', 'o', 0, 5)).not.toContain('-ss'); }); }); describe('time codes and probe', () => { test('previewFrameTimeCode', () => { expect(timeCode(5)).toBe('00:00:05:00'); expect(timeCode(2.5)).toBe('00:00:02:15'); expect(timeCode(29.99)).toBe('00:00:30:00'); expect(timeCode(29.9)).toBe('00:00:29:27'); expect(timeCode(3725.1)).toBe('01:02:05:03'); expect(timeCode(-1)).toBe('00:00:00:00'); }); const json = JSON.stringify({ streams: [{ codec_type: 'video', codec_name: 'h264', width: 886, height: 1920, avg_frame_rate: '30/1', profile: 'High' }, { codec_type: 'audio', codec_name: 'aac', channels: 2, sample_rate: '48000' }], format: { duration: '25.000' } }); const good = parseProbe(json, 30_000_000); test('parseProbe', () => { expect(good).toEqual({ bytes: 30_000_000, duration: 25, video: { codec: 'h264', w: 886, h: 1920, fps: 30, profile: 'High' }, audio: { codec: 'aac', channels: 2, sampleRate: 48000 } }); expect(parseProbe(JSON.stringify({ streams: [{ codec_type: 'video', codec_name: 'vp8', width: 2, height: 2, r_frame_rate: '30000/1001' }] }), 1).video!.fps).toBeCloseTo(29.97, 2); }); test('checkVideo: apple rules, youtube only size', () => { expect(checkVideo(good, 'iphone')).toEqual([]); const bad: ProbeInfo = { bytes: 600e6, duration: 40, video: { codec: 'hevc', w: 1080, h: 1920, fps: 60 }, audio: { codec: 'opus', channels: 1, sampleRate: 22050 } }; const msgs = checkVideo(bad, 'iphone').map(i => i.msg); expect(msgs.length).toBe(8); expect(checkVideo({ ...good, audio: undefined }, 'ipad').map(i => i.msg)).toEqual(['886x1920, App Store iPad 13" app preview wants 1200x1600', expect.stringContaining('no audio track')]); expect(checkVideo({ ...bad, video: { ...bad.video!, w: 1080, h: 1920 } }, 'youtube')).toEqual([]); expect(checkVideo(good, 'nope')[0]!.msg).toContain('unknown'); }); test('default targets per store config', () => { expect(defaultTargets({ apple: {}, play: {} })).toEqual(['iphone', 'youtube']); expect(defaultTargets({ apple: { ipad: true } })).toEqual(['iphone', 'ipad']); expect(defaultTargets({ play: {} })).toEqual(['youtube']); expect(defaultTargets({ play: {}, shots: { video: { targets: ['youtube-landscape'] } } })).toEqual(['youtube-landscape']); }); }); describe('record', () => { const cap = { source: 'web' as const, baseUrl: 'http://127.0.0.1:8740', shots: {} }; test('argv builders', () => { expect(simRecordArgs('U1', 'o.mp4')).toEqual(['simctl', 'io', 'U1', 'recordVideo', '--codec=h264', '--mask=ignored', '--force', 'o.mp4']); expect(adbRecordArgs('/sdcard/x.mp4', 400, 'emu-1')).toEqual(['-s', 'emu-1', 'shell', 'screenrecord', '--time-limit', '180', '/sdcard/x.mp4']); expect(adbPullArgs('/sdcard/x.mp4', 'l.mp4')).toEqual(['pull', '/sdcard/x.mp4', 'l.mp4']); expect(adbRmArgs('/sdcard/x.mp4', 's')).toEqual(['-s', 's', 'shell', 'rm', '-f', '/sdcard/x.mp4']); expect(clipName(0, 'intro')).toBe('01-intro.mp4'); expect(clipName(11, 'end')).toBe('12-end.mp4'); }); test('screencast frames -> concat list with real frame durations', () => { const list = concatList([{ file: '/t/f1.jpg', t: 100 }, { file: '/t/f2.jpg', t: 100.04 }, { file: "/t/it's.jpg", t: 100.5 }], 2); expect(list).toBe("file '/t/f1.jpg'\nduration 0.0400\nfile '/t/f2.jpg'\nduration 0.4600\nfile '/t/it'\\''s.jpg'\nduration 1.5000\nfile '/t/it'\\''s.jpg'\n"); expect(concatList([], 2)).toBe('\n'); const a = concatArgs('l.txt', 'o.mp4', 6); expect(a.slice(3, 12)).toEqual(['-f', 'concat', '-safe', '0', '-i', 'l.txt', '-t', '6.000', '-vf']); expect(a.at(-1)).toBe('o.mp4'); }); test('record size: device pixels (clip scale = dpr), long side capped', () => { // a 430x932 @2x context: the screencast gave 430x932, captureScreenshot with scale 2 gives 860x1864 expect(recordSize({ w: 430, h: 932, dpr: 2 })).toEqual({ width: 860, height: 1864, scale: 2 }); expect(recordSize({ w: 301, h: 401, dpr: 2 })).toEqual({ width: 602, height: 802, scale: 2 }); expect(recordSize({ w: 360, h: 640, dpr: 1 })).toEqual({ width: 360, height: 640, scale: 1 }); // default 3x phone (2706 px tall) and the 13" iPad: capped at the tallest store video, never below the frame's CSS size const phone = recordSize({ w: 440, h: 902, dpr: 3 }); expect(phone.height).toBe(RECORD_MAX_SIDE); expect(phone.width).toBe(937); expect(phone.scale).toBeCloseTo(1920 / 902, 6); expect(recordSize({ w: 1032, h: 1352, dpr: 2 })).toMatchObject({ width: 1466, height: 1920 }); expect(recordSize({ w: 440, h: 902 }).scale).toBe(2); }); test('capture guardrail: frames smaller than requested', () => { expect(captureShortfall({ w: 860, h: 1864 }, { width: 860, height: 1864 })).toBeNull(); expect(captureShortfall({ w: 937, h: 1919 }, { width: 937, height: 1920 })).toBeNull(); expect(captureShortfall({ w: 430, h: 932 }, { width: 860, height: 1864 })).toContain('frames are 430x932, asked for 860x1864'); }); test('config checks', () => { expect(validateRecord({ clips: { a: { seconds: 8, path: '/' } } }, cap)).toEqual([]); expect(validateRecord({ clips: {} }, cap)).toEqual(['shots.video.record.clips: at least one clip']); expect(validateRecord({ source: 'folder', clips: {} }, undefined)).toEqual([]); const e = validateRecord({ source: 'ios-sim', device: 'watch' as 'phone', clips: { 'a b': { seconds: 0 } } }, cap); expect(e).toEqual([ 'shots.video.record.device: phone | tablet', 'shots.video.record: source ios-sim needs shots.capture.ios (bundleId, device)', 'shots.video.record.clips.a b: name must be a file name (letters, digits, . _ -)', 'shots.video.record.clips.a b.seconds: 1-60', ]); expect(validateRecord({ clips: { a: { seconds: 5 } } }, cap)).toEqual(['shots.video.record.clips.a.path: URL path (source web)']); expect(validateRecord({ source: 'tape' as 'web', clips: {} }, cap)[0]).toContain('one of'); }); test('clips in config order, --only keeps the index', () => { const r = { clips: { intro: { seconds: 5, path: '/' }, combo: { seconds: 6, path: '/' }, map: { seconds: 5, path: '/' } } }; expect(clipsToRecord(r).map(([i, n]) => `${i}:${n}`)).toEqual(['0:intro', '1:combo', '2:map']); expect(clipsToRecord(r, ['map']).map(([i, n]) => clipName(i, n))).toEqual(['03-map.mp4']); }); test('validateConfig runs the record checks', () => { const c = { app: { name: 'G' }, languages: [{ id: 'en' }], shots: { copy: 'c', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a.png' }] }], capture: cap, video: { record: { clips: { a: { seconds: 99, path: '/' } } } } } } as unknown as ShipConfig; expect(validateConfig(c)).toContain('shots.video.record.clips.a.seconds: 1-60'); }); }); describe('apple previews', () => { test('preview sets per project', () => { expect(previewSets(undefined)).toEqual({ iphone: 'IPHONE_67' }); expect(previewSets({ ipad: true })).toEqual({ iphone: 'IPHONE_67', ipad: 'IPAD_PRO_3GEN_129' }); }); test('files: numbered videos, sorted, at most 3', () => { const list = () => ['10.mp4', '02.mov', '01.mp4', '03.m4v', '01-poster.png', 'x.mp4']; expect(previewFiles('d', list)).toEqual(['d/01.mp4', 'd/02.mov', 'd/03.m4v']); }); test('reservation attributes', () => { expect(mimeType('a.mov')).toBe('video/quicktime'); expect(previewAttrs('a.mp4', 5)).toEqual({ mimeType: 'video/mp4', previewFrameTimeCode: '00:00:05:00' }); }); test('MD5 idempotency', () => { expect(setUpToDate([{ checksum: 'a', state: 'COMPLETE' }, { checksum: 'b', state: 'PROCESSING' }], ['a', 'b'])).toBe(true); expect(setUpToDate([{ checksum: 'b', state: 'COMPLETE' }, { checksum: 'a', state: 'COMPLETE' }], ['a', 'b'])).toBe(false); expect(setUpToDate([{ checksum: 'a', state: 'FAILED' }], ['a'])).toBe(false); expect(setUpToDate([{ checksum: 'a', state: 'AWAITING_UPLOAD' }], ['a'])).toBe(false); expect(setUpToDate([], [])).toBe(true); expect(setUpToDate([{ checksum: 'a', state: 'COMPLETE' }], ['a', 'b'])).toBe(false); }); }); describe('play promo video', () => { const c = { lang: 'en-US', title: 'T', short: 'S', full: 'F' }; test('listing body carries play.video only when set', () => { expect(listingBody(c)).toEqual({ language: 'en-US', title: 'T', shortDescription: 'S', fullDescription: 'F' }); expect(listingBody(c, 'https://youtu.be/abcdefgh').video).toBe('https://youtu.be/abcdefgh'); }); test('play.video must be a plain YouTube URL', () => { const base = { app: { name: 'G' }, languages: [{ id: 'en' }] }; const errs = (video: string) => validateConfig({ ...base, play: { packageName: 'com.example.g', video } }).filter(e => e.startsWith('play.video')); expect(errs('https://www.youtube.com/watch?v=abcdefgh')).toEqual([]); expect(errs('https://youtu.be/abcdefgh')).toEqual([]); expect(errs('https://www.youtube.com/watch?v=abcdefgh&list=x').length).toBe(1); expect(errs('https://vimeo.com/123').length).toBe(1); }); });