// 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?v=4&u=' + encodeURIComponent(CLIP.saved), p0.poster); console.log('\n[waiting for a tap]'); await page.evaluate(new Function(`${box('garden')}.scrollIntoView({ block: 'center' })`)); await page.waitForFunction(new Function(`return !!${box('garden')}?.querySelector('.vid-heavy')`), { timeout: 10000 }); const idle = await page.evaluate(new Function(`const b = ${box('garden')}, v = b.querySelector('video'), s = b.querySelector('.vid-sound'); return { controls: v.controls, sound: s ? getComputedStyle(s).display : 'gone', fit: getComputedStyle(v).objectFit, label: b.querySelector('.vid-heavy').textContent };`)); check('only the play button and the cost show: no seek bar, no sound button, the picture fills the box', !idle.controls && idle.sound === 'none' && idle.fit === 'cover' && idle.label.length > 1, JSON.stringify(idle)); const at = await page.evaluate(new Function(`const r = ${box('garden')}.getBoundingClientRect(); return { x: r.x + r.width * 0.3, y: r.y + r.height * 0.3 };`)); await page.mouse.move(at.x, at.y); await sleep(300); const bg = await page.evaluate(new Function(`return getComputedStyle(${box('garden')}.querySelector('.vid-heavy')).backgroundColor`)); check('hovering the cover leaves the picture showing through', /rgba\(0, 0, 0, 0?\.\d+\)/.test(bg), bg); await page.mouse.move(0, 0); 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('playing, the player\'s own controls are back', await page.evaluate(new Function(`const b = ${box('garden')}; return b.querySelector('video').controls && !b.classList.contains('idle')`))); 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);