diff options
| author | Adam Soltys <[email protected]> | 2026-09-30 10:10:10 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-30 10:10:10 -0700 |
| commit | 505cd84813a80e0a69b2cc95aa93c68cf2856cb2 (patch) | |
| tree | 3453513920b23476e01efe508e73ba8651f438da /tools | |
| parent | 93c07bec934e8e8d847349b7792f4cc9089adbb7 (diff) | |
| download | coinosv3-505cd84813a80e0a69b2cc95aa93c68cf2856cb2.tar.gz coinosv3-505cd84813a80e0a69b2cc95aa93c68cf2856cb2.zip | |
Videos are shrunk on the phone before upload; nostr.build is the first media host.
No public Blossom host transcodes (nostr.build's and primal's BUD-05 /media
return the same bitrate), so a 15-20 Mbps phone clip stalled on playback.
src/video-compress.js re-encodes on the device with WebCodecs via
Mediabunny (a lazily loaded chunk, left out of the SW precache and the
standalone file): H.264, short edge 720, ~2.5 Mbps, moov first, audio
passed through, rotation baked in. Anything short of a clear win uploads
the original. A toast counts the shrinking down.
Media hosts: blossom.nostr.build first, nostr.download second (it was
unreachable for hours); a HEAD probe skips a host that doesn't answer
within 5 s instead of waiting out the whole upload timeout.
video-compress-test: 1080p 20 Mbps 5 s (landscape and rotated portrait) →
1.6 MB H.264 720p 2.49 Mbps with audio, moov first, in 2-3 s.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/media-upload-test.js | 18 | ||||
| -rw-r--r-- | tools/video-compress-test.js | 101 |
2 files changed, 119 insertions, 0 deletions
diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js index f9cc56a..e19cac7 100644 --- a/tools/media-upload-test.js +++ b/tools/media-upload-test.js @@ -10,6 +10,7 @@ let signed = null; const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); const calls = []; const fetcher = async (url, init) => { + if (init?.method === 'HEAD') return new Response('', { status: 200 }); // the reachability probe calls.push({ url, init }); if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); @@ -23,6 +24,23 @@ check('hashes the file and binds the hash into Blossom auth', calls[1].init.head && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); +// a host that never answers is skipped after the probe, not waited on +{ + const puts = []; + const t0 = Date.now(); + const u = await uploadPublicMedia(file, sign, { + servers: ['https://dead.example', 'https://alive.example'], timeoutMs: 60_000, + fetcher: async (url, init) => { + if (url.startsWith('https://dead')) return new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new Error('timeout')))); + if (init.method === 'HEAD') return new Response('', { status: 404 }); + puts.push(url); + return new Response(JSON.stringify({ url: 'https://alive.example/x.mov' }), { status: 201 }); + }, + }); + check('an unreachable host is skipped within seconds', u === 'https://alive.example/x.mov' && Date.now() - t0 < 7000 && puts.length === 1); +} +check('nostr.build is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.nostr.build'); + const bareHashUrl = await uploadPublicMedia(file, sign, { servers: ['https://media.example'], timeoutMs: 1000, fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js new file mode 100644 index 0000000..dc17036 --- /dev/null +++ b/tools/video-compress-test.js @@ -0,0 +1,101 @@ +// A phone video is shrunk on the device before upload: 1080p at 20 Mbps in, +// H.264 720p around 2.5 Mbps out, moov first (streams while downloading), +// audio kept. Pasted into the reply box like a keyboard/gallery pick; the +// upload is caught and its bytes checked with ffprobe. +// +// Run: bun tools/video-compress-test.js +import puppeteer from 'puppeteer-core'; +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 SRC = '/tmp/coinos-video-compress-src.mp4', OUT = '/tmp/coinos-video-compress-out.mp4'; +// --portrait: the way phones record upright video — landscape frames plus a +// 90° rotation flag in the container +const PORTRAIT = process.argv.includes('--portrait'); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '5', '-c:v', 'libx264', '-b:v', '20M', '-maxrate', '20M', '-bufsize', '20M', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', SRC]); +if (PORTRAIT) { + // (ffmpeg 4 writes the flag only on a remux) + Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', SRC, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', SRC + '.rot.mp4']); + Bun.spawnSync(['mv', SRC + '.rot.mp4', SRC]); + const r = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', SRC]).stdout)).streams[0]; + console.log(' source rotation:', JSON.stringify(r.side_data_list || r.tags?.rotate || 'none')); +} +const srcSize = Bun.file(SRC).size; +// the split build: the compressor and Mediabunny are chunks loaded on demand +const built = Bun.spawnSync(['bun', 'run', 'build.js'], { stdout: 'pipe', stderr: 'pipe' }); +if (built.exitCode) { console.log(new TextDecoder().decode(built.stderr)); process.exit(1); } +const dist = 'dist'; +const server = Bun.serve({ port: 5287, fetch: async (req) => { + const p = new URL(req.url).pathname; + if (p === '/src.mp4') return new Response(Bun.file(SRC), { headers: { 'content-type': 'video/mp4' } }); + const f = Bun.file(dist + (p === '/' || !p.includes('.') ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file(dist + '/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + const f = window.fetch; + window.fetch = async (url, init) => { + if (init && init.method === 'PUT' && /nostr\.build|nostr\.download/.test(String(url))) { + window.__put = { size: init.body.size, type: init.body.type, buf: await init.body.arrayBuffer() }; + return new Response(JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }), { status: 201 }); + } + if (init && init.method === 'HEAD' && /nostr\.build|nostr\.download/.test(String(url))) return new Response('', { status: 200 }); + return f(url, init); + }; +}); +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, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5287/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(600); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported)); + // the post composer: the pencil on the feed + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(1500); + await page.evaluate(() => document.querySelector('button[aria-label="New post"], button[title="New post"]')?.click()); + const box = await waitFor(() => !!document.querySelector('.post-input'), 8000); + check('the post composer opens', box); + const t0 = Date.now(); + await page.evaluate(async () => { + const b = await (await fetch('/src.mp4')).blob(); + const dt = new DataTransfer(); + dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' })); + document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + const sawShrink = await waitText('shrinking the video', 15000); + check('the toast says it is shrinking the video', sawShrink); + const done = await waitFor(() => !!window.__put, 120000); + const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type })); + const secs = ((Date.now() - t0) / 1000).toFixed(1); + check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35, + put ? `${(srcSize / 1e6).toFixed(1)} MB → ${(put.size / 1e6).toFixed(1)} MB in ${secs}s` : 'no upload'); + if (put) { + const b64 = await page.evaluate(() => { let s = ''; const u = new Uint8Array(window.__put.buf); for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); }); + await Bun.write(OUT, Buffer.from(b64, 'base64')); + const probe = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', OUT]).stdout)); + const v = probe.streams.find((s) => s.codec_type === 'video'), a = probe.streams.find((s) => s.codec_type === 'audio'); + check('...H.264 at 720p', v?.codec_name === 'h264' && Math.min(v.width, v.height) === 720, `${v?.codec_name} ${v?.width}x${v?.height}`); + if (PORTRAIT) { + // upright on screen: either the frames are portrait, or they keep the rotation flag + const rot = Math.abs(+((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0)); + check('...still upright (portrait)', (v.height > v.width) !== (rot === 90 || rot === 270), `${v.width}x${v.height} rotation ${rot}`); + } + check('...around 2.5 Mbps', v && +v.bit_rate < 3.5e6, (+v?.bit_rate / 1e6).toFixed(2) + ' Mbps'); + check('...the whole clip, with its sound', Math.abs(+probe.format.duration - 5) < 0.3 && !!a, probe.format.duration + 's, audio ' + a?.codec_name); + const head = new Uint8Array(await Bun.file(OUT).slice(0, 4096).arrayBuffer()); + const txt = new TextDecoder('latin1').decode(head); + check('...moov before mdat, so it plays while downloading', txt.indexOf('moov') >= 0 && (txt.indexOf('mdat') < 0 || txt.indexOf('moov') < txt.indexOf('mdat'))); + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ videos are shrunk before upload' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
