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
121
122
123
124
125
126
|
// 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.js';
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();
// the upload goes out by XMLHttpRequest (for its progress events): keep a
// copy of the body, and answer at the network layer — nothing leaves
await page.evaluateOnNewDocument(() => {
const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, url) { this.__m = m; this.__url = String(url); return open.apply(this, arguments); };
XMLHttpRequest.prototype.send = function (body) {
if (this.__m === 'PUT' && /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.build|nostr\.download/.test(this.__url) && body instanceof Blob) {
body.arrayBuffer().then((buf) => { window.__put = { size: body.size, type: body.type, buf }; });
}
return send.apply(this, arguments);
};
// every state the progress bar shows
window.__prog = [];
new MutationObserver(() => {
const m = document.querySelector('.media-progress');
const s = m ? m.innerText.replace(/\s+/g, ' ').trim() : null;
if (s && window.__prog.at(-1) !== s) window.__prog.push(s);
}).observe(document, { childList: true, subtree: true, characterData: true, attributes: true });
});
await page.setRequestInterception(true);
page.on('request', (req) => {
const u = req.url();
if (!/blossom\.ditto\.pub|blossom\.primal\.net|nostr\.build|nostr\.download/.test(u)) return req.continue();
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' };
if (req.method() === 'OPTIONS') return req.respond({ status: 204, headers: cors });
if (req.method() === 'HEAD') return req.respond({ status: 200, headers: cors });
return req.respond({ status: 201, headers: cors, contentType: 'application/json',
body: JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }) });
});
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 done = await waitFor(() => !!window.__put, 120000);
await waitFor(() => !document.querySelector('.media-progress'), 10000);
const prog = await page.evaluate(() => window.__prog);
const pcts = (stage) => prog.filter((x) => x.startsWith(stage)).map((x) => +x.match(/(\d+)%/)[1]);
const shrink = pcts('Shrinking video'), up = pcts('Uploading');
check('a bar shows the shrinking with its percentage', shrink.length >= 3 && shrink.some((p) => p > 0 && p < 100), JSON.stringify(shrink.slice(0, 12)));
// (the answer here is instant, so only its start shows — the percentages
// themselves are upload-progress-test's)
check('...then the upload with its own', up.length >= 1 && prog.findIndex((x) => x.startsWith('Uploading')) > prog.findIndex((x) => x.startsWith('Shrinking')), JSON.stringify(up));
check('...and goes away when it is done', await page.evaluate(() => !document.querySelector('.media-progress')));
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);
|