aboutsummaryrefslogtreecommitdiffstats
path: root/lib/buildliquidtx.js
diff options
context:
space:
mode:
Diffstat (limited to 'lib/buildliquidtx.js')
0 files changed, 0 insertions, 0 deletions
d='n108' href='#n108'>108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 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);