aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
4 files changed, 79 insertions, 5 deletions
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 {}
+ }
+}