aboutsummaryrefslogtreecommitdiffstats
path: root/tools/video-hls-play-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/video-hls-play-test.js')
-rw-r--r--tools/video-hls-play-test.js133
1 files changed, 133 insertions, 0 deletions
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..f1aea1c
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,133 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), and so does one too big
+// to play as it is (past 1080p: it used to wait for a tap); 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');
+// short but past 1080p: it used to wait for a tap with its cost on it
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']);
+const BIG = readFileSync(DIR + '/coinos-hls-big.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) });
+const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`);
+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', big: 'https://media.test/big.mp4' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)];
+
+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.set('u', local(u.searchParams.get('u')));
+ 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 : u.pathname === '/big.mp4' ? BIG : 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 four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, 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 past 1080p]');
+ await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`));
+ check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000),
+ JSON.stringify(await player('square')));
+
+ 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);