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
|
// 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 {}
}
}
// A long video as an HLS stream (Adam, 2026-10-18; jb55: "you should use
// hls (m3u8 ext) for long videos"): a 720p and a 360p rendition, the
// player taking the one the connection carries, so an hour-long clip
// neither stalls on mobile data nor costs the whole file to watch a
// minute of it. Made here, like the shrinking above, in 4-second segments,
// each rendition (and the sound) one file read in ranges — a handful of
// uploads, not hundreds. The files keep the names the playlists give them;
// uploadStream puts them on a host and the playlists on after them.
// Anything short of a stream — a short clip, no WebCodecs, an error — is
// null, and the video goes up as one file as before.
export const STREAM_MIN_S = 90;
export async function streamVideo(file, onProgress = () => {}) {
if (!file || !/^video\//.test(file.type || '')) return null;
if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return null;
let mb;
try { mb = await import('mediabunny'); } catch { return null; }
const { Input, Output, Conversion, BlobSource, BufferTarget, PathedTarget, HlsOutputFormat, CmafOutputFormat, ALL_FORMATS } = mb;
if (!HlsOutputFormat || !PathedTarget) return null;
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
try {
const video = await input.getPrimaryVideoTrack();
if (!video) return null;
const seconds = await input.computeDuration();
if (!(seconds >= STREAM_MIN_S)) return null;
const w = video.displayWidth, h = video.displayHeight, short = Math.min(w, h);
const edge = (n) => (w >= h ? { height: Math.min(n, short) } : { width: Math.min(n, short) });
const rungs = [{ ...edge(SHORT_EDGE), bitrate: VIDEO_BPS }, { ...edge(360), bitrate: 800_000 }];
const files = new Map(); // path -> { target, mime }
const target = new PathedTarget('master.m3u8', (req) => {
const t = new BufferTarget();
files.set(req.path, { target: t, mime: req.mimeType });
return t;
});
const output = new Output({
target,
format: new HlsOutputFormat({
segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, targetDuration: 4,
getPlaylistPath: ({ n }) => `p${n}.m3u8`,
getSegmentPath: ({ playlist }) => `s${playlist.n}.mp4`, // (a host serves .mp4 to anyone; .m4s it may not take)
}),
});
const conversion = await Conversion.init({
input, output,
video: rungs.map((r) => ({ ...r, codec: 'avc', keyFrameInterval: 4, forceTranscode: true })),
});
if (!conversion.isValid) return null;
conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
await conversion.execute();
const out = new Map();
for (const [path, { target: t, mime }] of files) {
if (!t.buffer) return null;
out.set(path, new File([t.buffer], path, { type: /m3u8$/.test(path) ? 'application/vnd.apple.mpegurl' : mime || 'video/mp4' }));
}
if (!out.has('master.m3u8')) return null;
const dim = w >= h ? `${Math.round(w * Math.min(1, SHORT_EDGE / short))}x${Math.min(SHORT_EDGE, short)}` : `${Math.min(SHORT_EDGE, short)}x${Math.round(h * Math.min(1, SHORT_EDGE / short))}`;
return { files: out, master: 'master.m3u8', seconds, dim };
} catch {
return null;
} finally {
try { input.dispose?.(); } catch {}
}
}
|