aboutsummaryrefslogtreecommitdiffstats
path: root/src/video-compress.js
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 /src/video-compress.js
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
Diffstat (limited to 'src/video-compress.js')
-rw-r--r--src/video-compress.js54
1 files changed, 54 insertions, 0 deletions
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 {}
+ }
+}