aboutsummaryrefslogtreecommitdiffstats
path: root/src/video-compress.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/video-compress.js')
-rw-r--r--src/video-compress.js63
1 files changed, 63 insertions, 0 deletions
diff --git a/src/video-compress.js b/src/video-compress.js
index 53d4eb0..8fe0856 100644
--- a/src/video-compress.js
+++ b/src/video-compress.js
@@ -52,3 +52,66 @@ export async function compressVideo(file, onProgress = () => {}) {
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 {}
+ }
+}