// 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('', { 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);