aboutsummaryrefslogtreecommitdiffstats
path: root/vmedia/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'vmedia/server.js')
-rw-r--r--vmedia/server.js199
1 files changed, 193 insertions, 6 deletions
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');