aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-30 10:10:10 -0700
committerAdam Soltys <[email protected]>2026-09-30 10:10:10 -0700
commit505cd84813a80e0a69b2cc95aa93c68cf2856cb2 (patch)
tree3453513920b23476e01efe508e73ba8651f438da
parent93c07bec934e8e8d847349b7792f4cc9089adbb7 (diff)
downloadcoinosv3-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
-rw-r--r--build.js13
-rw-r--r--bun.lock7
-rw-r--r--package.json1
-rw-r--r--src/app.js11
-rw-r--r--src/i18n.js2
-rw-r--r--src/media-upload.js17
-rw-r--r--src/video-compress.js54
-rw-r--r--tools/media-upload-test.js18
-rw-r--r--tools/video-compress-test.js101
9 files changed, 217 insertions, 7 deletions
diff --git a/build.js b/build.js
index c90e527..2bc5361 100644
--- a/build.js
+++ b/build.js
@@ -332,7 +332,13 @@ async function bundleApp({ minify, features, staging, splitting = false }) {
// The split build emits real ESM chunks (deferred features arrive as their
// own files); the single-file build inlines the dynamic imports instead.
...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}),
- plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging)],
+ plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging),
+ // the single file inlines every dynamic import: the video compressor
+ // (Mediabunny, ~700 KB) would ride along in a file meant to be small
+ // and offline — there it uploads the original, as before
+ ...(splitting ? [] : [{ name: 'no-video-compress', setup(build) {
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }])],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
@@ -470,7 +476,10 @@ if (import.meta.main) {
const nwcSw = feats.includes('nwc') && feats.includes('ark');
const swBody = SW
.replaceAll('{{VERSION}}', version)
- .replaceAll('{{EXTRA_SHELL}}', chunks.map((c) => `, '${c.name}'`).join(''))
+ // precached for offline use — except what only works online anyway:
+ // the video compressor (Mediabunny, ~700 KB) loads when a video is
+ // picked for upload, not on every install
+ .replaceAll('{{EXTRA_SHELL}}', chunks.filter((c) => !c.text.includes('Mediabunny Codec Registry')).map((c) => `, '${c.name}'`).join(''))
.replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY);
await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody);
for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f));
diff --git a/bun.lock b/bun.lock
index 658bc63..10e82e2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,6 +12,7 @@
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
"jsqr": "^1.4.0",
+ "mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
"qrcode-generator": "^2.0.4",
},
@@ -43,6 +44,10 @@
"@scure/btc-signer": ["@scure/[email protected]", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5", "micro-packed": "~0.7.3" } }, "sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ=="],
+ "@types/dom-mediacapture-transform": ["@types/[email protected]", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="],
+
+ "@types/dom-webcodecs": ["@types/[email protected]", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
+
"ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -67,6 +72,8 @@
"jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
+ "mediabunny": ["[email protected]", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A=="],
+
"micro-packed": ["[email protected]", "", { "dependencies": { "@scure/base": "~1.2.5" } }, "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg=="],
"mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
diff --git a/package.json b/package.json
index 6783bf9..1d12b14 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
"jsqr": "^1.4.0",
+ "mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
"qrcode-generator": "^2.0.4"
},
diff --git a/src/app.js b/src/app.js
index 0bf3086..5b0bdb6 100644
--- a/src/app.js
+++ b/src/app.js
@@ -3483,6 +3483,17 @@ async function onbUpload(file) {
}
async function publicMediaUpload(file) {
+ // a phone video is shrunk on the device first (see video-compress.js);
+ // the toast counts it down, since a long clip takes a while
+ if (/^video\//.test(file?.type || '')) {
+ let shown = -1;
+ const { compressVideo } = await import('./video-compress.js');
+ file = await compressVideo(file, (p) => {
+ const pct = Math.floor(p * 100);
+ if (pct !== shown && pct % 5 === 0) { shown = pct; toast(t('mediaShrinking', { pct }), 60_000); }
+ });
+ if (shown >= 0) toast(t('mediaUploading'), 4000);
+ }
try {
return await uploadPublicMedia(file, (event) => wallet.nostrSign(event));
} catch (error) {
diff --git a/src/i18n.js b/src/i18n.js
index 41ccb36..f54e760 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -245,6 +245,8 @@ const STR = {
feedDelete: 'Delete feed',
feedSaveAdhoc: 'Save feed',
feedSaved: 'Feed saved',
+ mediaShrinking: 'Shrinking the video for streaming… {pct}%',
+ mediaUploading: 'Uploading…',
feedNoQuery: 'Add people or topics to this feed.',
feedOfTitle: '{name}’s timeline',
feedOfLoading: 'Reading who they follow…',
diff --git a/src/media-upload.js b/src/media-upload.js
index 686fec5..8e5e8ed 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -2,10 +2,11 @@ import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
// Independent public hosts: a phone upload should not fail just because one
-// provider is down or rejects a particular container. nostr.download's NIP-96
-// document currently advertises large image/video/audio uploads; nostr.build
-// is the second, widely-used Blossom home.
-export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build'];
+// provider is down or rejects a particular container. nostr.build is the
+// widely-used Blossom home and goes first; nostr.download (which advertises
+// large image/video/audio uploads) is the fallback — it was unreachable for
+// hours on 2026-09-30, and every upload waited on it.
+export const PUBLIC_MEDIA_SERVERS = ['https://blossom.nostr.build', 'https://nostr.download'];
export class MediaUploadError extends Error {
constructor(code, details = '') {
@@ -59,7 +60,13 @@ export async function uploadPublicMedia(file, signEvent, {
if (!auth) throw new MediaUploadError('failed');
const authorization = 'Nostr ' + btoa(JSON.stringify(auth));
const failures = [];
- for (const server of servers) {
+ for (const [i, server] of servers.entries()) {
+ // A host that doesn't answer at all costs a few seconds, not the whole
+ // upload timeout. Any HTTP answer, even an error, means it's there.
+ if (i < servers.length - 1) {
+ try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); }
+ catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; }
+ }
try {
const response = await fetcher(server.replace(/\/$/, '') + '/upload', {
method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs),
diff --git a/src/video-compress.js b/src/video-compress.js
new file mode 100644
index 0000000..53d4eb0
--- /dev/null
+++ b/src/video-compress.js
@@ -0,0 +1,54 @@
+// A phone's video, made light enough to stream before it's uploaded.
+//
+// A phone records 1080p–4K at 15–20 Mbps; played back from a Blossom host
+// over mobile data that stalls every few seconds, and none of the public
+// hosts transcode (nostr.build's and primal's BUD-05 /media endpoints hand
+// back the same bitrate — measured 2026-09-30). So the phone does it:
+// WebCodecs on the device's hardware encoder, via Mediabunny (loaded only
+// when a video is picked), to H.264 at 720p and ~2.5 Mbps, the moov atom up
+// front so playback starts before the download ends. Audio passes through
+// untouched when the container allows (phones record AAC already).
+//
+// Anything short of a clear win — no WebCodecs, an unreadable file, an
+// encoder error, a result not meaningfully smaller — uploads the original,
+// exactly as before.
+
+const SHORT_EDGE = 720;
+const VIDEO_BPS = 2_500_000;
+
+export async function compressVideo(file, onProgress = () => {}) {
+ if (!file || !/^video\//.test(file.type || '')) return file;
+ if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return file;
+ let mb;
+ try { mb = await import('mediabunny'); } catch { return file; }
+ const { Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, ALL_FORMATS } = mb;
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
+ try {
+ const video = await input.getPrimaryVideoTrack();
+ if (!video) return file;
+ const seconds = await input.computeDuration();
+ const w = video.displayWidth, h = video.displayHeight;
+ const short = Math.min(w, h);
+ const bps = seconds > 0 ? (file.size * 8) / seconds : Infinity;
+ // already light: nothing to gain, and a re-encode only loses quality
+ if (short <= SHORT_EDGE && bps <= VIDEO_BPS * 1.4) return file;
+ const size = short > SHORT_EDGE ? (w >= h ? { height: SHORT_EDGE } : { width: SHORT_EDGE }) : {};
+ const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() });
+ const conversion = await Conversion.init({
+ input, output,
+ video: { ...size, codec: 'avc', bitrate: VIDEO_BPS, forceTranscode: true },
+ });
+ if (!conversion.isValid) return file;
+ conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
+ await conversion.execute();
+ const buf = output.target.buffer;
+ // not worth it unless it's clearly smaller
+ if (!buf || buf.byteLength > file.size * 0.8) return file;
+ const name = String(file.name || 'video').replace(/\.[a-z0-9]{1,8}$/i, '') + '.mp4';
+ return new File([buf], name, { type: 'video/mp4' });
+ } catch {
+ return file;
+ } finally {
+ try { input.dispose?.(); } catch {}
+ }
+}
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);