diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 05:54:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 05:54:23 -0700 |
| commit | b9297719d294779a2ea14c77a67975308bace65c (patch) | |
| tree | d005658191f0ce4dec1a97212d6f4525900b60c0 | |
| parent | 31661e28b1a010fa67b3a2527c43a129ab2a41cc (diff) | |
| download | coinosv3-b9297719d294779a2ea14c77a67975308bace65c.tar.gz coinosv3-b9297719d294779a2ea14c77a67975308bace65c.zip | |
HLS for long videos: vmedia serves a long clip as a 360p/720p stream made as it's watched (/vmedia/hls.m3u8), the feed and pager play long clips and posted .m3u8 streams through the browser's own HLS, and a long video posted from the app goes up as an HLS stream made on the device (master playlist on Blossom).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
| -rw-r--r-- | src/app.js | 14 | ||||
| -rw-r--r-- | src/features/messages.js | 104 | ||||
| -rw-r--r-- | src/media-upload.js | 46 | ||||
| -rw-r--r-- | src/video-compress.js | 63 | ||||
| -rw-r--r-- | tools/hls-upload-test.js | 92 | ||||
| -rw-r--r-- | tools/video-hls-play-test.js | 123 | ||||
| -rw-r--r-- | tools/vmedia-hls-test.js | 84 | ||||
| -rw-r--r-- | vmedia/server.js | 199 |
8 files changed, 701 insertions, 24 deletions
@@ -11,7 +11,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec import { assetUrl } from './asset-url.js'; import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; -import { uploadPublicMedia, MediaUploadError } from './media-upload.js'; +import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; @@ -3829,10 +3829,18 @@ function mediaProgress(stage, fraction) { } async function publicMediaUpload(file) { try { - // a phone video is shrunk on the device first (see video-compress.js) + // a phone video is shrunk on the device first (see video-compress.js); + // a long one becomes an HLS stream, its master playlist the address if (/^video\//.test(file?.type || '')) { mediaProgress('shrink', 0); - const { compressVideo } = await import('./video-compress.js'); + const { compressVideo, streamVideo } = await import('./video-compress.js'); + const stream = await streamVideo(file, (p) => mediaProgress('shrink', p)); + if (stream) { + mediaProgress('upload', 0); + const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); + return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) }; + } + mediaProgress('shrink', 0); file = await compressVideo(file, (p) => mediaProgress('shrink', p)); } mediaProgress('upload', 0); diff --git a/src/features/messages.js b/src/features/messages.js index ada978a..693c12f 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -88,7 +88,7 @@ export function messagesFeature(ctx) { const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), - ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; + ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -6471,21 +6471,23 @@ export function messagesFeature(ctx) { // publish it); otherwise the metadata alone is fetched — a few KB for a // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. - const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; }; - // A video event's file: of the versions its imeta tags offer, the one - // nearest 1080 on its short side (a phone's screen; not the 4K original), - // never an HLS playlist; else its url tag. + // A video event's file: its HLS stream where the browser plays one (the + // player picks the rendition); else of the versions its imeta tags + // offer, the one nearest 1080 on its short side (a phone's screen; not the + // 4K original); else its url tag. function videoEventFile(ev) { - let best = null; + let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); - if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue; + if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } + if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; @@ -6493,6 +6495,7 @@ export function messagesFeature(ctx) { if (!best || score > best.score) best = { url: f.url, image: f.image, score }; } if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; } + if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) }; if (best) videoUrls.add(best.url); return best; } @@ -6610,7 +6613,15 @@ export function messagesFeature(ctx) { // an iPhone's HEVC (hvc1/hev1 in the sample description) let hevc = false; for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true; - return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) }; + // its length, from the movie header (a long clip plays as HLS: playSrc) + let dur = 0; + for (let j = 4; j + 32 <= b.length && !dur; j++) { + if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm' + const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16)); + const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20); + if (scale && units && units !== 0xffffffff) dur = units / scale; + } + return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) }; } const videoProbes = new Map(); let videoProbing = 0; @@ -6638,6 +6649,7 @@ export function messagesFeature(ctx) { } } async function runVideoProbe(url) { + if (isHls(url)) return probeStream(url); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10_000); try { @@ -6664,7 +6676,7 @@ export function messagesFeature(ctx) { let o = 0; for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); - const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) }; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; if (total > 0) d.size = total; else { // a server that hides Content-Range from scripts (Primal's) still @@ -6683,6 +6695,25 @@ export function messagesFeature(ctx) { } finally { clearTimeout(timer); } return videoInfo.get(url); } + // A stream is measured by a player: its playlist says nothing of the + // picture's size. Where HLS doesn't play, it can't be measured either. + async function probeStream(url) { + if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); } + const v = document.createElement('video'); + v.muted = true; v.preload = 'metadata'; + const ok = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 15_000); + v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = url; + }); + if (ok) { + noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) }); + rememberVideo(url, v.videoWidth, v.videoHeight); + } else noteVideoInfo(url, { failedAt: Date.now() }); + v.removeAttribute('src'); v.load(); + return videoInfo.get(url); + } // How much a clip may cost before it waits for a tap: less on a slow or // metered connection. const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } }; @@ -6700,6 +6731,7 @@ export function messagesFeature(ctx) { function videoHeavy(url) { if (tappedClips.has(url) || largeVideosOk()) return false; const d = videoInfo.get(url) || {}; + if (d.stream) return false; // a stream takes the rendition the connection carries if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; if (!d.size) return true; // nothing known: don't spend data blind if (d.size > videoBudget()) return true; @@ -7838,10 +7870,27 @@ export function messagesFeature(ctx) { const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); - // what a player for this clip loads: the copy, once the original is known - // not to show here - const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) - && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A long clip (or a big one whose length isn't known) plays as HLS from + // vmedia: a 360p and a 720p rendition, the player taking the one the + // connection carries, in 4-second pieces made as they're watched — not a + // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you + // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS + // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the + // original as before. + const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; + const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); + const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); + const hlsFailed = new Set(); + let hlsPlays; + const canHls = () => hlsPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } + })(); + const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; + // what a player for this clip loads: the stream for a long one; the copy, + // once the original is known not to show here + const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url + : longClip(url) && canHls() && !hlsFailed.has(url) ? hlsOf(url) + : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. @@ -7857,6 +7906,14 @@ export function messagesFeature(ctx) { }); v.addEventListener('error', () => { if (!onCopy()) return; + // the stream failed: the copy or the original, as it would have been + if (isHls(v.getAttribute('src'))) { + hlsFailed.add(url); + const want = !v.paused; + v.setAttribute('src', playSrc(url)); + if (want) v.play().catch(() => {}); + return; + } h264Failed.add(url); const want = !v.paused; v.setAttribute('src', url); @@ -11394,11 +11451,13 @@ export function messagesFeature(ctx) { if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await upload(file); + // (a URL, or what a stream's upload says of it: its type, size, length) + const got = await upload(file); + const url = typeof got === 'string' ? got : got.url; // A post canceled while its upload was in flight must stay canceled. // `false` lets that composer reject the late result without affecting // the reply composer, which shares this helper. - if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); + if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } @@ -12980,6 +13039,11 @@ export function messagesFeature(ctx) { function headStart(url, share = 1) { if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); if (headStarted.has(url)) return headStarted.get(url); + if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's + const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + return task; + } const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { @@ -12999,6 +13063,16 @@ export function messagesFeature(ctx) { if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } return task; } + async function hlsHead(url) { + const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; }; + const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; }; + const master = await get(url), text = await master.text(); + // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself + let list = text, at = master.url || url; + if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); } + const seg = uri(list, at); + if (seg) await (await get(seg)).arrayBuffer(); + } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); if (!v) return; diff --git a/src/media-upload.js b/src/media-upload.js index c06771b..e45d2fe 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -115,3 +115,49 @@ export async function uploadPublicMedia(file, signEvent, { if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); throw new MediaUploadError('failed', details); } + +// An HLS stream (streamVideo) onto one host: the media files first, then +// each playlist with its files' addresses written in, then the master +// playlist naming those — its address is the post's. One host for the lot, +// and one that serves a playlist as one (ditto and nostr.download do; +// primal's says text/plain, nostr.build won't take it); a host that refuses +// any piece is passed over for the next. `files` maps each name the +// playlists use to its File. +export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net']; +export function rewritePlaylist(text, urls) { + const to = (p) => urls.get(p) || p; + return text.split('\n').map((line) => { + const l = line.trim(); + if (l && !l.startsWith('#')) return to(l); + return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`); + }).join('\n'); +} +export async function uploadStream({ files, master }, signEvent, { servers = STREAM_SERVERS, fetcher, onProgress = null } = {}) { + const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p)); + const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master); + const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1; + let lastError = null; + for (const server of servers) { + let done = 0; + const put = async (file) => { + const url = await uploadPublicMedia(file, signEvent, { + servers: [server], ...(fetcher ? { fetcher } : {}), + onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))), + }); + done += file.size; + if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in) + return url; + }; + try { + const urls = new Map(); + for (const p of media) urls.set(p, await put(files.get(p))); + for (const p of lists) { + const text = rewritePlaylist(await files.get(p).text(), urls); + urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' }))); + } + const text = rewritePlaylist(await files.get(master).text(), urls); + return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' })); + } catch (error) { lastError = error; } + } + throw lastError || new MediaUploadError('failed'); +} 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 {} + } +} diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js new file mode 100644 index 0000000..0a3feea --- /dev/null +++ b/tools/hls-upload-test.js @@ -0,0 +1,92 @@ +// A long video posted from the app goes up as an HLS stream: made on the +// device (two renditions, a file each, read in ranges), put on one Blossom +// host with the playlists naming the files' addresses, and the master +// playlist's address is what the post carries. Chrome plays it from there +// and seeks in it. A short clip is no stream. +// Run: bun tools/hls-upload-test.js +import puppeteer from './puppeteer.js'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const dir = mkdtempSync(tmpdir() + '/hls-upload-'); +const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]); +make(95, dir + '/long.mp4'); +make(20, dir + '/short.mp4'); +const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true }); +if (!built.success) throw new Error(built.logs.join('\n')); +const js = new Map(); +for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text()); +// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges +const blobs = new Map(), types = new Map(); +const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) { + const u = new URL(req.url); + if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } }); + if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname)); + if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 }); + if (u.pathname === '/upload' && req.method === 'PUT') { + const body = new Uint8Array(await req.arrayBuffer()); + const x = createHash('sha256').update(body).digest('hex'); + blobs.set(x, body); types.set(x, req.headers.get('content-type')); + return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length }); + } + const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname); + if (m && blobs.has(m[1])) { + const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' }; + if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); } + return new Response(buf, { headers }); + } + return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + await page.goto(server.url.href); + const run = (name) => page.evaluate(async (name) => { + const vc = await import('/video-compress.js'), mu = await import('/media-upload.js'); + const blob = await (await fetch('/' + name)).blob(); + const file = new File([blob], name, { type: 'video/mp4' }); + const t0 = performance.now(); + const stream = await vc.streamVideo(file); + if (!stream) return { stream: null }; + const made = Math.round(performance.now() - t0); + let progress = 0; + const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } }); + return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds }; + }, name); + const short = await run('short.mp4'); + check('a 20 s clip is not made a stream', short.stream === null); + const long = await run('long.mp4'); + check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms'); + check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url); + check('...the upload reported its progress to the end', long.progress === 1, String(long.progress)); + check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds); + const master = await (await fetch(long.url)).text(); + const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : [])); + check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' ')); + check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master)); + const media = await (await fetch(lists.at(-1))).text(); + check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media)); + const played = await page.evaluate(async (src) => { + const v = document.querySelector('video'); + v.src = src; + await v.play().catch(() => {}); + const t0 = performance.now(); + while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); + const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message }; + v.currentTime = 70; + const t1 = performance.now(); + while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); + return { first, after: v.currentTime, err: v.error && v.error.message }; + }, long.url); + check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first)); + check('...and seeks in it', played.after >= 71, JSON.stringify(played)); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); + rmSync(dir, { recursive: true, force: true }); +} +console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js new file mode 100644 index 0000000..5bf6809 --- /dev/null +++ b/tools/video-hls-play-test.js @@ -0,0 +1,123 @@ +// A long clip in the feed plays as HLS from vmedia (a rendition the +// connection carries, in pieces made as watched), a short one as it is, +// and a post linking an .m3u8 stream plays that. vmedia runs here for real, +// on a local copy of the clip. +// Run: bun tools/video-hls-play-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { readFileSync, rmSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const DIR = process.env.CLIP_DIR || '/tmp'; +const ff = (out, codec) => { + const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', + '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]); + if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); } + return readFileSync(out); +}; +const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']); +const LONG = readFileSync(DIR + '/coinos-hls-long.mp4'); +// vmedia itself, reading the clip from here +const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(LONG) }); +const LOCAL_SRC = `http://127.0.0.1:${files.port}/long.mp4`; +process.env.VMEDIA_LOCAL = '1'; +process.env.DATA = DIR + '/coinos-hls-vmedia'; +const vm = await import('../vmedia/server.js'); +vm.startTrim(); +const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8' }; +const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300)]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.setRequestInterception(true); +const asked = []; // [kind, clip url] +// vmedia's answers, from the real thing; the clip's host serves the files +const vmedia = async (u) => { + if (u.pathname === '/vmedia/hls.m3u8' && u.searchParams.get('u') === CLIP.long) u.searchParams.set('u', LOCAL_SRC); + const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search)); + return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } }; +}; +page.on('request', async (r) => { + const u = new URL(r.url()); + if (u.pathname.startsWith('/vmedia/hls')) { + asked.push(['hls', u.pathname]); + return r.respond(await vmedia(u)); + } + if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } }); + if (u.host === 'media.test') { + asked.push([r.resourceType(), r.url()]); + // the stream a post links: vmedia's of the long clip, served from its host + if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); } + return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : SHORT, headers: { 'access-control-allow-origin': '*' } }); + } + r.continue(); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`; +const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video'); + return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`)); +const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`)); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the three clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 3, null, 20000), + String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length))); + + console.log('\n[a long clip]'); + await show('lecture'); + check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000), + JSON.stringify(await player('lecture'))); + check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4))); + + console.log('\n[a short clip]'); + await show('quick'); + check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000), + JSON.stringify(await player('quick'))); + + console.log('\n[a post linking a stream]'); + await show('episode'); + check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000), + JSON.stringify(await player('episode'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); } +console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js new file mode 100644 index 0000000..05961a8 --- /dev/null +++ b/tools/vmedia-hls-test.js @@ -0,0 +1,84 @@ +// vmedia's HLS: a clip's playlists come from its length before anything is +// made, segments are made from wherever the player asks (a seek starts the +// encoder there), the encoder waits once well ahead of the player, and +// Chrome plays the stream and seeks in it. +// Run: bun tools/vmedia-hls-test.js +import puppeteer from './puppeteer.js'; +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const dir = mkdtempSync(tmpdir() + '/vmedia-hls-'); +const clip = dir + '/clip.mp4'; +// a minute of 1080p with sound +Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]); +const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) }); +const SRC = `http://127.0.0.1:${files.port}/clip.mp4`; +process.env.VMEDIA_LOCAL = '1'; +process.env.DATA = dir + '/data'; +const vm = await import('../vmedia/server.js'); +vm.startTrim(); +const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle }); +const V = `http://127.0.0.1:${api.port}/vmedia`; +const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => { + const u = new URL(req.url); + if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers }); + return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } }); +} }); +let browser; +try { + console.log('[the playlists]'); + const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text(); + check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300)); + const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1]; + const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text(); + const segs = media.split('\n').filter((l) => /\.ts$/.test(l)); + check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments'); + check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720')); + check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400); + + console.log('\n[segments, made as asked]'); + let t0 = Date.now(); + const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`); + const b0 = await s0.arrayBuffer(); + check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms'); + t0 = Date.now(); + const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`); + await s10.arrayBuffer(); + check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms'); + const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format; + const p0 = probe(0), p12 = probe(12); + check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time); + await sleep(4000); + const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f)); + check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' ')); + + console.log('\n[Chrome plays it]'); + browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); + const page = await browser.newPage(); + await page.goto(page0.url.href); + const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`; + const played = await page.evaluate(async (src) => { + const v = document.querySelector('video'); + v.src = src; + await v.play().catch(() => {}); + const t0 = performance.now(); + while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); + const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration }; + v.currentTime = 30; + const t1 = performance.now(); + while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100)); + return { first, after: v.currentTime, err: v.error && v.error.message }; + }, hlsSrc); + check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first)); + check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur)); + check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played)); +} catch (e) { console.error(e); ok = false; } finally { + if (browser) await browser.close(); + files.stop(true); api.stop(true); page0.stop(true); + rmSync(dir, { recursive: true, force: true }); +} +console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/vmedia/server.js b/vmedia/server.js index 0f11200..6b6b69a 100644 --- a/vmedia/server.js +++ b/vmedia/server.js @@ -20,18 +20,26 @@ // camera file froze a wheel scroll for 400 ms on its texture upload. // libvips makes the copy (shrink-on-load: the original is never decoded // whole), turned the way its EXIF says, stripped, as WebP. +// /vmedia/hls.m3u8?u=<clip> the clip as HLS (Adam, 2026-10-18; jb55: "you +// should use hls (m3u8 ext) for long videos"): a 360p and a 720p H.264 +// rendition in 4-second segments, the player choosing between them as +// the connection allows. Nothing is made ahead of time: the playlists +// are written from the clip's length, and each rendition's encoder runs +// from the segment asked for, a minute ahead of the player at most (then +// paused), restarted wherever a seek lands, and stopped once nobody is +// asking. Segments made are kept, like the copies. // // Only https sources on public addresses; ffmpeg may speak nothing else. A // few jobs at a time, a per-address hourly allowance for new transcodes, // and both caches trimmed to a size. -import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync } from 'node:fs'; +import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; import { createHash } from 'node:crypto'; const DATA = process.env.DATA || '/data'; -const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work'; -for (const d of [THUMBS, CLIPS, PICS, WORK]) mkdirSync(d, { recursive: true }); +const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work', HLS = DATA + '/hls'; +for (const d of [THUMBS, CLIPS, PICS, WORK, HLS]) mkdirSync(d, { recursive: true }); const THUMB_CAP = 2e9, CLIP_CAP = 25e9, PIC_CAP = 8e9; const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6, PIC_JOBS = 4, PIC_QUEUE = 60; const CLIP_PER_HOUR = 12; // new transcodes per address @@ -55,9 +63,11 @@ const privateV6 = (ip) => { }; export const privateIp = (ip) => (isIP(ip) === 6 ? privateV6(ip) : privateV4(ip)); // a clip worth fetching: https, an ordinary port, a public name +const LOCAL = process.env.VMEDIA_LOCAL === '1'; // tests: clips served over http from this machine export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) { let u; try { u = new URL(raw); } catch { return null; } + if (LOCAL && u.protocol === 'http:' && u.hostname === '127.0.0.1') return u.href; if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443') || raw.length > 2000) return null; const host = u.hostname.replace(/^\[|\]$/g, ''); if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) return null; @@ -69,7 +79,7 @@ export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) { } // ---- ffmpeg --------------------------------------------------------------- -const NET = ['-protocol_whitelist', 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia']; +const NET = ['-protocol_whitelist', LOCAL ? 'http,https,tls,tcp,crypto' : 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia']; // longest side at most 1280, even numbers for the encoder export const SCALE_720 = "scale='trunc(min(1,1280/max(iw,ih))*iw/2)*2':'trunc(min(1,1280/max(iw,ih))*ih/2)*2'"; export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src, @@ -228,6 +238,171 @@ function tail(job) { }); } +// ---- HLS -------------------------------------------------------------------- +const SEG = 4; // seconds a segment +const HLS_CAP = 30e9, HLS_ENCODERS = 6, HLS_PER_HOUR = 30, HLS_MAX_SECONDS = 4 * 3600; +const AHEAD = 15; // segments an encoder runs ahead of the player before it waits +const IDLE_MS = 90_000; // an encoder nobody has asked anything of for this long stops +const RUNGS = [{ name: '360', long: 640, vb: 800, ab: 96 }, { name: '720', long: 1280, vb: 2800, ab: 128 }]; +const HLS_V = 1; +const hlsKey = (src) => keyOf('h' + HLS_V + '|' + src); +const hlsAllowance = new Map(); +const even = (x) => Math.max(2, Math.round(x / 2) * 2); +// the rendition sizes a clip gets: never larger than it is; one at least +export function rungsFor(w, h) { + const long = Math.max(w, h); + const out = RUNGS.filter((r, i) => !i || long >= r.long * 0.9).map((r) => { + const k = Math.min(1, r.long / long); + return { ...r, w: even(w * k), h: even(h * k) }; + }); + return out; +} +// what ffprobe says: its length, its picture's size the way it's shown, any sound +export function readProbe(j) { + const v = (j.streams || []).find((s) => s.codec_type === 'video'); + const dur = Number(j.format?.duration); + if (!v || !v.width || !v.height || !(dur > 0)) return null; + const rot = Number((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0); + const turned = Math.abs(rot) % 180 === 90; + return { dur, w: turned ? v.height : v.width, h: turned ? v.width : v.height, audio: (j.streams || []).some((s) => s.codec_type === 'audio') }; +} +const probing = new Map(); +async function hlsInfo(src, key) { + const dir = `${HLS}/${key}`, file = `${dir}/info.json`; + if (existsSync(file)) { touch(dir); try { return JSON.parse(readFileSync(file, 'utf8')); } catch {} } + if (failedLately(key)) return null; + if (!probing.has(key)) probing.set(key, (async () => { + const p = Bun.spawn(['ffprobe', '-v', 'error', ...NET, '-show_format', '-show_streams', '-of', 'json', src], { stdout: 'pipe', stderr: 'ignore' }); + const timer = setTimeout(() => p.kill(9), 25_000); + const out = await new Response(p.stdout).text(); + await p.exited.finally(() => clearTimeout(timer)); + let info = null; + try { info = readProbe(JSON.parse(out)); } catch {} + if (!info || info.dur > HLS_MAX_SECONDS) { failed.set(key, Date.now()); return null; } + info = { src, ...info, rungs: rungsFor(info.w, info.h) }; + mkdirSync(dir, { recursive: true }); + writeFileSync(file, JSON.stringify(info)); + return info; + })().finally(() => probing.delete(key))); + return probing.get(key); +} +const segCount = (info) => Math.max(1, Math.ceil(info.dur / SEG - 0.01)); +export function masterPlaylist(info, key) { + const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-INDEPENDENT-SEGMENTS']; + for (const r of info.rungs) { + const bw = Math.round((r.vb * 1.1 + (info.audio ? r.ab : 0)) * 1000); + lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bw},RESOLUTION=${r.w}x${r.h},CODECS="avc1.640028${info.audio ? ',mp4a.40.2' : ''}"`, `/vmedia/hls/${key}/${r.name}.m3u8`); + } + return lines.join('\n') + '\n'; +} +// the whole clip's segments, known from its length before any is made +export function mediaPlaylist(info, rung) { + const n = segCount(info); + const lines = ['#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${SEG}`, '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-MEDIA-SEQUENCE:0']; + for (let i = 0; i < n; i++) { + const d = i < n - 1 ? SEG : Math.max(0.1, info.dur - SEG * (n - 1)); + lines.push(`#EXTINF:${d.toFixed(3)},`, `${rung}/${String(i).padStart(5, '0')}.ts`); + } + lines.push('#EXT-X-ENDLIST'); + return lines.join('\n') + '\n'; +} +// One rendition from segment `from` on. The keyframes fall every SEG +// seconds of the clip, so a segment made by a restarted encoder is the one +// the first would have made (its times shifted by where it started). +export const hlsArgs = (src, info, r, from, dir) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, + '-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_delay_max', '5', + ...(from ? ['-ss', String(from * SEG)] : []), '-i', src, + '-map', '0:v:0', ...(info.audio ? ['-map', '0:a:0'] : []), + '-vf', `scale=${r.w}:${r.h}`, '-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-pix_fmt', 'yuv420p', + '-b:v', r.vb + 'k', '-maxrate', Math.round(r.vb * 1.2) + 'k', '-bufsize', r.vb * 2 + 'k', '-threads', '2', + '-force_key_frames', `expr:gte(t,n_forced*${SEG})`, + ...(info.audio ? ['-c:a', 'aac', '-b:a', r.ab + 'k', '-ac', '2'] : []), + ...(from ? ['-output_ts_offset', String(from * SEG)] : []), + '-f', 'hls', '-hls_time', String(SEG), '-hls_list_size', '0', '-hls_segment_type', 'mpegts', '-hls_flags', 'temp_file', + '-start_number', String(from), '-hls_segment_filename', `${dir}/%05d.ts`, `${dir}/enc.m3u8`]; +const encoders = new Map(); // key/rung -> { p, from, next, last, at, paused, failed } +const segPath = (dir, n) => `${dir}/${String(n).padStart(5, '0')}.ts`; +function stopEncoder(id) { + const e = encoders.get(id); + if (!e) return; + encoders.delete(id); + try { if (e.paused) e.p.kill('SIGCONT'); e.p.kill(9); } catch {} +} +function startEncoder(id, src, info, r, from, dir) { + stopEncoder(id); + if (encoders.size >= HLS_ENCODERS) { + // the one asked of longest ago makes way, if it isn't in use + const [oldId, old] = [...encoders].sort((a, b) => a[1].at - b[1].at)[0]; + if (Date.now() - old.at < 10_000) return null; + stopEncoder(oldId); + } + mkdirSync(dir, { recursive: true }); + const p = Bun.spawn(['ffmpeg', ...hlsArgs(src, info, r, from, dir)], { stdout: 'ignore', stderr: 'pipe' }); + const e = { p, from, next: from, last: from, at: Date.now(), paused: false, done: false, ok: false }; + encoders.set(id, e); + p.exited.then(async (code) => { + e.done = true; e.ok = code === 0; + if (code !== 0 && code !== null && encoders.get(id) === e) console.log('hls encode failed', src, r.name, from, (await new Response(p.stderr).text()).slice(-300)); + if (encoders.get(id) === e) encoders.delete(id); + }); + return e; +} +// how far each encoder has got; ahead of its player it waits, idle it stops +function tendEncoders() { + for (const [id, e] of encoders) { + const dir = `${HLS}/${id}`; + while (existsSync(segPath(dir, e.next))) e.next++; + if (Date.now() - e.at > IDLE_MS) { stopEncoder(id); continue; } + try { + if (!e.paused && e.next - e.last > AHEAD) { e.p.kill('SIGSTOP'); e.paused = true; } + else if (e.paused && e.next - e.last <= AHEAD - 3) { e.p.kill('SIGCONT'); e.paused = false; } + } catch {} + } +} +async function segResponse(req, key, rungName, n) { + const dir = `${HLS}/${key}`; + let info = null; + try { info = JSON.parse(readFileSync(`${dir}/info.json`, 'utf8')); } catch {} + const r = info && info.rungs.find((x) => x.name === rungName); + if (!r || n >= segCount(info)) return nope(404, 'No segment'); + touch(dir); + const id = `${key}/${rungName}`, rdir = `${dir}/${rungName}`, file = segPath(rdir, n); + let e = encoders.get(id); + if (e) { e.at = Date.now(); e.last = Math.max(e.from, n); } + if (existsSync(file)) return fileResponse(req, file, 'video/mp2t'); + // an encoder that will reach it soon, or one started there + if (e) while (existsSync(segPath(rdir, e.next))) e.next++; + if (!e || e.done || n < e.from || n > e.next + 2) e = startEncoder(id, info.src, info, r, n, rdir); + if (!e) return nope(503, 'Busy'); + e.last = n; + tendEncoders(); + const t0 = Date.now(); + while (!existsSync(file) && Date.now() - t0 < 45_000) { + if (e.done) break; + await Bun.sleep(100); + } + return existsSync(file) ? fileResponse(req, file, 'video/mp2t') : nope(e.done ? 404 : 503, 'Not made'); +} +const M3U8 = 'application/vnd.apple.mpegurl'; +const playlist = (text) => new Response(text, { headers: { 'Content-Type': M3U8, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' } }); +async function hlsResponse(req, route, u, ip) { + if (route === '/hls.m3u8') { + const src = await sourceOk(u.searchParams.get('u') || ''); + if (!src) return nope(400, 'Not a public https URL'); + const key = hlsKey(src); + if (!existsSync(`${HLS}/${key}/info.json`) && !failedLately(key) && !probing.has(key) && !allowed(ip, hlsAllowance, HLS_PER_HOUR)) return nope(429, 'Too many'); + const info = await hlsInfo(src, key); + return info ? playlist(masterPlaylist(info, key)) : nope(404, 'Not playable'); + } + const m = /^\/hls\/([0-9a-f]{64})\/(\w+)(?:\.m3u8|\/(\d{5})\.ts)$/.exec(route); + if (!m) return nope(404, 'Not found'); + if (m[3] != null) return segResponse(req, m[1], m[2], Number(m[3])); + let info = null; + try { info = JSON.parse(readFileSync(`${HLS}/${m[1]}/info.json`, 'utf8')); } catch {} + if (!info || !info.rungs.some((r) => r.name === m[2])) return nope(404, 'Not found'); + return playlist(mediaPlaylist(info, m[2])); +} + // ---- responses -------------------------------------------------------------- const WEEK = 'public, max-age=604800, immutable'; function fileResponse(req, path, type) { @@ -363,10 +538,21 @@ function trim(dir, cap) { for (const f of files) { if (total <= cap) break; try { unlinkSync(f.p); total -= f.size; } catch {} } } catch {} } +// the HLS clips go whole, the one asked of longest ago first (not one being made) +function trimHls(cap) { + try { + const size = (p) => { let n = 0; for (const f of readdirSync(p, { withFileTypes: true })) n += f.isDirectory() ? size(p + '/' + f.name) : sizeOf(p + '/' + f.name); return n; }; + const busy = new Set([...encoders.keys()].map((id) => id.split('/')[0])); + const dirs = readdirSync(HLS).filter((k) => !busy.has(k)).map((k) => ({ k, at: statSync(HLS + '/' + k).mtimeMs, size: size(HLS + '/' + k) })).sort((a, b) => a.at - b.at); + let total = dirs.reduce((n, d) => n + d.size, 0); + for (const d of dirs) { if (total <= cap) break; try { rmSync(HLS + '/' + d.k, { recursive: true, force: true }); total -= d.size; } catch {} } + } catch {} +} export function startTrim() { - const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); }; + const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); trimHls(HLS_CAP); }; go(); setInterval(go, 10 * 60_000); + setInterval(tendEncoders, 1000); // work left over from a restart is no good to anyone for (const f of readdirSync(WORK)) try { unlinkSync(WORK + '/' + f); } catch {} } @@ -375,10 +561,11 @@ export async function handle(req) { const u = new URL(req.url); if (!['GET', 'HEAD'].includes(req.method)) return nope(405, 'GET only'); const route = u.pathname.replace(/^\/vmedia/, ''); + const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown'; + if (route.startsWith('/hls')) return hlsResponse(req, route, u, ip); if (route !== '/thumb.jpg' && route !== '/first.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found'); const src = await sourceOk(u.searchParams.get('u') || ''); if (!src) return nope(400, 'Not a public https URL'); - const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown'; if (route === '/pic.webp') { const shape = u.searchParams.get('s') || 'col'; return SHAPES[shape] ? picResponse(req, src, shape, ip) : nope(400, 'Unknown shape'); |
