diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 11 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/media-upload.js | 17 | ||||
| -rw-r--r-- | src/video-compress.js | 54 |
4 files changed, 79 insertions, 5 deletions
@@ -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 {} + } +} |
