diff options
Diffstat (limited to 'vmedia/server.js')
| -rw-r--r-- | vmedia/server.js | 231 |
1 files changed, 222 insertions, 9 deletions
diff --git a/vmedia/server.js b/vmedia/server.js index b2f5bae..6b6b69a 100644 --- a/vmedia/server.js +++ b/vmedia/server.js @@ -4,6 +4,9 @@ // /vmedia/thumb.jpg?u=<clip> a still from the clip's first half second, // 480px wide — the poster a post's imeta didn't give. A 4K or 134 MB // clip waits for a tap, so without one the feed showed a black box. +// /vmedia/first.jpg?u=<clip> the clip's very first frame, at its size +// (longest side 1280): the very picture a player shows first, so the +// video pager's slide ahead holds it before the player has it. // /vmedia/h264.mp4?u=<clip> the clip as H.264/AAC at 720p, for a browser // that can't decode the original's picture (an iPhone's HEVC on Chrome // for Linux plays its sound over black). The first ask streams the @@ -17,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 @@ -52,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; @@ -66,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, @@ -74,6 +87,9 @@ export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel' // that fades in from black has a black frame at half a second '-frames:v', '1', '-vf', "scale='min(480,iw)':-2," + (lift ? 'normalize=strength=0.7:smoothing=0,' : '') + "signalstats,metadata=mode=print:key=lavfi.signalstats.YAVG:file='pipe\\:2'", '-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']; +// frame 0 itself, as big as the player shows it (the pager's slide ahead) +export const firstArgs = (src) => ['-hide_banner', '-loglevel', 'error', ...NET, '-i', src, + '-frames:v', '1', '-vf', SCALE_720, '-q:v', '3', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']; export const clipArgs = (src, out) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, '-i', src, '-map', '0:v:0', '-map', '0:a:0?', '-t', String(MAX_SECONDS), '-vf', SCALE_720, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-threads', '4', @@ -131,6 +147,26 @@ async function makeThumb(src, key) { failed.set(key, Date.now()); return null; } +// The very first frame, full size, for the video pager: the slide ahead +// holds the picture its player will show first, so a flick glides onto it +// and the player takes over with no visible change. Dark is dark: this one +// is meant to match the player, not to show what the clip is. +async function makeFirst(src, key) { + const out = `${THUMBS}/${key}-f.jpg`; + const { p, done } = run(firstArgs(src), 25_000); + const [buf] = await Promise.all([new Response(p.stdout).arrayBuffer(), done]); + if (p.exitCode === 0 && buf.byteLength > 500) { await Bun.write(out, buf); return out; } + failed.set(key, Date.now()); + return null; +} +const FIRST_V = 1; +async function first(src) { + const key = keyOf('f' + FIRST_V + '|' + src), out = `${THUMBS}/${key}-f.jpg`; + if (existsSync(out)) { touch(out); return out; } + if (failedLately(key)) return null; + if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeFirst(src, key)).finally(() => thumbJobs.delete(key))); + return thumbJobs.get(key); +} const THUMB_V = 4; // v4: past a dark opening frame (stills made before are made again) async function thumb(src) { const key = keyOf('v' + THUMB_V + '|' + src), out = `${THUMBS}/${key}.jpg`; @@ -202,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) { @@ -337,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 {} } @@ -349,16 +561,17 @@ 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/, ''); - if (route !== '/thumb.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found'); + 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'); } - if (route === '/thumb.jpg') { - const p = await thumb(src); + if (route === '/thumb.jpg' || route === '/first.jpg') { + const p = await (route === '/first.jpg' ? first(src) : thumb(src)); return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture'); } return clipResponse(req, src, ip); |
