201 lines
13 KiB
TypeScript
201 lines
13 KiB
TypeScript
// 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<string, string[]> = { '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);
|
|
});
|
|
});
|