aboutsummaryrefslogtreecommitdiffstats
path: root/tools/video-fallback-test.js
blob: 81c22d32a6bd2369a1bff0fa82f55c101b726131 (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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// A clip this browser can't picture (an iPhone's HEVC on Chrome for Linux
// played its sound over black) switches to the server's H.264 copy; if the
// copy fails too, the sound plays under a line saying why. A clip whose
// post gave no poster wears the server's still. The server (vmedia) is
// stood in for here: its answers are canned.
// Run: bun tools/video-fallback-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 } 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 HEVC = ff(DIR + '/coinos-fallback-hevc.mp4', ['-c:v', 'libx265', '-tag:v', 'hvc1', '-x265-params', 'log-level=error']);
const H264 = ff(DIR + '/coinos-fallback-h264.mp4', ['-c:v', 'libx264']);
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 = { saved: 'https://media.test/saved.mp4', lost: 'https://media.test/lost.mp4', known: 'https://media.test/known.mp4' };
const seeded = [post('garden ' + CLIP.saved, 100), post('broken ' + CLIP.lost, 200), post('probed ' + CLIP.known, 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]
page.on('request', (r) => {
  const u = new URL(r.url());
  if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) {
    const src = u.searchParams.get('u');
    asked.push([u.pathname, src]);
    if (u.pathname === '/vmedia/thumb.jpg') return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
    if (src === CLIP.lost) return r.respond({ status: 404, body: 'Not playable' });
    return r.respond({ status: 200, contentType: 'video/mp4', body: H264, headers: { 'access-control-allow-origin': '*' } });
  }
  if (u.host === 'media.test') {
    asked.push([r.resourceType(), r.url()]);
    // the probe is answered for one clip only: the others are found out by playing
    if (r.resourceType() === 'fetch' && r.url() !== CLIP.known) return r.abort();
    return r.respond({ status: 200, contentType: 'video/mp4', body: HEVC, 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 {
  const hevcHere = await page.evaluate(() => document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'));
  if (hevcHere) console.log(' (this Chrome decodes HEVC; the clip-switch checks can\'t fail here)');
  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));

  console.log('\n[no poster in the post]');
  const p0 = await player('garden');
  check('the server\'s still is the poster', p0.poster === 'https://v3.coinos.io/vmedia/thumb.jpg?u=' + encodeURIComponent(CLIP.saved), p0.poster);

  console.log('\n[found out by playing]');
  await show('garden');
  check('the clip switches to the H.264 copy and shows its picture', await waitFor(new Function(`const v = ${box('garden')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/h264.mp4') && v.videoWidth > 0;`), null, 10000),
    JSON.stringify(await player('garden')));
  check('the original was tried first', asked.some(([k, u]) => k === 'media' && u === CLIP.saved));

  console.log('\n[the copy fails too]');
  await show('broken');
  check('its sound plays under a line saying why, with the file', await waitFor(new Function(`const b = ${box('broken')}; const v = b && b.querySelector('video'); return v && b.querySelector('.vid-nopic') && v.getAttribute('src') === '${CLIP.lost}';`), null, 10000),
    JSON.stringify(await player('broken')));
  const p1 = await player('broken');
  check('…which says the browser can\'t show it', /can.t show/.test(p1.note) && /Open the file/.test(p1.note), p1.note);

  console.log('\n[known from the probe]');
  await sleep(500);
  await show('probed');
  check('an HEVC header seen by the probe plays the copy from the start', await waitFor(new Function(`const v = ${box('probed')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/h264.mp4') && v.videoWidth > 0;`), null, 10000),
    JSON.stringify(await player('probed')));
  check('…without the player fetching the original', !asked.some(([k, u]) => k === 'media' && u === CLIP.known), JSON.stringify(asked.filter(([, u]) => u === CLIP.known)));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ clips without a picture here get the copy, or say why' : '\n❌ failed');
process.exit(ok ? 0 : 1);