diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/hls-upload-test.js | 92 | ||||
| -rw-r--r-- | tools/video-hls-play-test.js | 123 | ||||
| -rw-r--r-- | tools/vmedia-hls-test.js | 84 |
3 files changed, 299 insertions, 0 deletions
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js new file mode 100644 index 0000000..0a3feea --- /dev/null +++ b/tools/hls-upload-test.js @@ -0,0 +1,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); diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js new file mode 100644 index 0000000..5bf6809 --- /dev/null +++ b/tools/video-hls-play-test.js @@ -0,0 +1,123 @@ +// A long clip in the feed plays as HLS from vmedia (a rendition the +// connection carries, in pieces made as watched), a short one as it is, +// and a post linking an .m3u8 stream plays that. vmedia runs here for real, +// on a local copy of the clip. +// Run: bun tools/video-hls-play-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { readFileSync, rmSync } from 'node:fs'; +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 = process.env.CLIP_DIR || '/tmp'; +const ff = (out, codec) => { + const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', + '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]); + if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); } + return readFileSync(out); +}; +const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']); +const LONG = readFileSync(DIR + '/coinos-hls-long.mp4'); +// vmedia itself, reading the clip from here +const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(LONG) }); +const LOCAL_SRC = `http://127.0.0.1:${files.port}/long.mp4`; +process.env.VMEDIA_LOCAL = '1'; +process.env.DATA = DIR + '/coinos-hls-vmedia'; +const vm = await import('../vmedia/server.js'); +vm.startTrim(); +const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8' }; +const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300)]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const 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.setRequestInterception(true); +const asked = []; // [kind, clip url] +// vmedia's answers, from the real thing; the clip's host serves the files +const vmedia = async (u) => { + if (u.pathname === '/vmedia/hls.m3u8' && u.searchParams.get('u') === CLIP.long) u.searchParams.set('u', LOCAL_SRC); + const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search)); + return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } }; +}; +page.on('request', async (r) => { + const u = new URL(r.url()); + if (u.pathname.startsWith('/vmedia/hls')) { + asked.push(['hls', u.pathname]); + return r.respond(await vmedia(u)); + } + if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } }); + if (u.host === 'media.test') { + asked.push([r.resourceType(), r.url()]); + // the stream a post links: vmedia's of the long clip, served from its host + if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); } + return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : SHORT, headers: { 'access-control-allow-origin': '*' } }); + } + r.continue(); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`; +const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video'); + return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`)); +const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`)); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the three clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 3, null, 20000), + String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length))); + + console.log('\n[a long clip]'); + await show('lecture'); + check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000), + JSON.stringify(await player('lecture'))); + check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4))); + + console.log('\n[a short clip]'); + await show('quick'); + check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000), + JSON.stringify(await player('quick'))); + + console.log('\n[a post linking a stream]'); + await show('episode'); + check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000), + JSON.stringify(await player('episode'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); } +console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js new file mode 100644 index 0000000..05961a8 --- /dev/null +++ b/tools/vmedia-hls-test.js @@ -0,0 +1,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); |
