aboutsummaryrefslogtreecommitdiffstats
path: root/tools/hls-upload-test.js
blob: 0a3feea37dfec08fcbedc0bce38c6b61ccccbd69 (plain) (blame)
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
85
86
87
88
89
90
91
92
// 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 /<hash>.<ext> 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('<!doctype html><video muted></video>', { 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);