// A long video posted from the app goes up as an HLS stream: made on the // device (two renditions, a file each, read in ranges), put on one Blossom // host with the playlists naming the files' addresses, and the master // playlist's address is what the post carries. Chrome plays it from there // and seeks in it. A short clip is no stream. // Run: bun tools/hls-upload-test.js import puppeteer from './puppeteer.js'; import { createHash } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const dir = mkdtempSync(tmpdir() + '/hls-upload-'); const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]); make(95, dir + '/long.mp4'); make(20, dir + '/short.mp4'); const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true }); if (!built.success) throw new Error(built.logs.join('\n')); const js = new Map(); for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text()); // a Blossom host: PUT /upload keeps the body under its hash; GET /. serves it in ranges const blobs = new Map(), types = new Map(); const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) { const u = new URL(req.url); if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } }); if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname)); if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 }); if (u.pathname === '/upload' && req.method === 'PUT') { const body = new Uint8Array(await req.arrayBuffer()); const x = createHash('sha256').update(body).digest('hex'); blobs.set(x, body); types.set(x, req.headers.get('content-type')); return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length }); } const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname); if (m && blobs.has(m[1])) { const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' }; if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); } return new Response(buf, { headers }); } return new Response('', { headers: { 'content-type': 'text/html' } }); } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); try { const page = await browser.newPage(); await page.goto(server.url.href); const run = (name) => page.evaluate(async (name) => { const vc = await import('/video-compress.js'), mu = await import('/media-upload.js'); const blob = await (await fetch('/' + name)).blob(); const file = new File([blob], name, { type: 'video/mp4' }); const t0 = performance.now(); const stream = await vc.streamVideo(file); if (!stream) return { stream: null }; const made = Math.round(performance.now() - t0); let progress = 0; const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } }); return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds }; }, name); const short = await run('short.mp4'); check('a 20 s clip is not made a stream', short.stream === null); const long = await run('long.mp4'); check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms'); check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url); check('...the upload reported its progress to the end', long.progress === 1, String(long.progress)); check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds); const master = await (await fetch(long.url)).text(); const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : [])); check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' ')); check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master)); const media = await (await fetch(lists.at(-1))).text(); check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media)); 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 < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message }; v.currentTime = 70; const t1 = performance.now(); while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); return { first, after: v.currentTime, err: v.error && v.error.message }; }, long.url); check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first)); check('...and seeks in it', played.after >= 71, JSON.stringify(played)); } catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); rmSync(dir, { recursive: true, force: true }); } console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed'); process.exit(ok ? 0 : 1);