1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
// vmedia's HLS: a clip's playlists come from its length before anything is
// made, segments are made from wherever the player asks (a seek starts the
// encoder there), the encoder waits once well ahead of the player, and
// Chrome plays the stream and seeks in it.
// Run: bun tools/vmedia-hls-test.js
import puppeteer from './puppeteer.js';
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
const clip = dir + '/clip.mp4';
// a minute of 1080p with sound
Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
process.env.VMEDIA_LOCAL = '1';
process.env.DATA = dir + '/data';
const vm = await import('../vmedia/server.js');
vm.startTrim();
const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
const V = `http://127.0.0.1:${api.port}/vmedia`;
const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
const u = new URL(req.url);
if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
} });
let browser;
try {
console.log('[the playlists]');
const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
console.log('\n[segments, made as asked]');
let t0 = Date.now();
const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
const b0 = await s0.arrayBuffer();
check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
t0 = Date.now();
const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
await s10.arrayBuffer();
check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
const p0 = probe(0), p12 = probe(12);
check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time);
await sleep(4000);
const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
console.log('\n[Chrome plays it]');
browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
await page.goto(page0.url.href);
const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
const played = await page.evaluate(async (src) => {
const v = document.querySelector('video');
v.src = src;
await v.play().catch(() => {});
const t0 = performance.now();
while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
v.currentTime = 30;
const t1 = performance.now();
while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
return { first, after: v.currentTime, err: v.error && v.error.message };
}, hlsSrc);
check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
} catch (e) { console.error(e); ok = false; } finally {
if (browser) await browser.close();
files.stop(true); api.stop(true); page0.stop(true);
rmSync(dir, { recursive: true, force: true });
}
console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|