diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 16:16:28 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 16:16:28 -0700 |
| commit | 738bace91bcd17c0ab8ba920fe2f0765f6504fc8 (patch) | |
| tree | cfc620c8a759ae3fc924711956b4b5c7a0218d30 /tools | |
| parent | 3ffe565b3d50539470a05b1f7a65f5fed419daf5 (diff) | |
| download | coinosv3-738bace91bcd17c0ab8ba920fe2f0765f6504fc8.tar.gz coinosv3-738bace91bcd17c0ab8ba920fe2f0765f6504fc8.zip | |
Videos: posters for clips that came without one, and an H.264 copy for a clip this browser can't picture (an iPhone's HEVC on Chrome for Linux played sound over black) — vmedia container on cs (bun + ffmpeg, /vmedia/), switch on zero-width metadata or an HEVC header from the probe, a line saying why if the copy fails too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/video-fallback-test.js | 120 | ||||
| -rw-r--r-- | tools/video-unmute-test.js | 1 |
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'; |
