aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/video-fallback-test.js120
-rw-r--r--tools/video-unmute-test.js1
2 files changed, 121 insertions, 0 deletions
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
new file mode 100644
index 0000000..81c22d3
--- /dev/null
+++ b/tools/video-fallback-test.js
@@ -0,0 +1,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);
diff --git a/tools/video-unmute-test.js b/tools/video-unmute-test.js
index ac7cfc4..6aef88e 100644
--- a/tools/video-unmute-test.js
+++ b/tools/video-unmute-test.js
@@ -20,6 +20,7 @@ const server = Bun.serve({ port: 5302, fetch(req) {
// and no clip over the data budget
const videoThumbs = new Map(), videoInfo = new Map(), tappedClips = new Set();
const feedMedia = () => null, rememberVideo = () => {}, probeVideo = async () => {}, videoHeavy = () => false, heavyLabel = () => '';
+ const APP_BASE = 'https://v3.coinos.io';
${dom}
${video}
const url = '/clip.webm';