// Video help for the feed, served at v3.coinos.io/vmedia/ (nginx → this // container, which carries ffmpeg): // // /vmedia/thumb.jpg?u= 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= 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= 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 // transcode as it's made (fragmented MP4); once done it's a plain // faststart file served in ranges and cached for everyone. // /vmedia/pic.webp?s=col|tile&u= the picture at the size the // feed paints it: 1100px wide for the column, or a 360px square of its // middle for the grid. The app makes these copies itself from a host that // allows CORS; one that doesn't (primal's blossom, nostrcheck, most // personal sites) left the painting the original, and a 9528×6328 // 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= 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, 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', 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 const PIC_PER_HOUR = 600; // new picture copies per address (a scroll through a feed asks for dozens) const PIC_BYTES = 60e6, PIC_FETCH_MS = 20_000, PIC_MAKE_MS = 30_000; const MAX_SECONDS = 900; const FAIL_TTL = 3_600_000; // ---- sources -------------------------------------------------------------- const privateV4 = (ip) => { const [a, b] = ip.split('.').map(Number); return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || a >= 224; }; const privateV6 = (ip) => { const x = ip.toLowerCase(); if (x === '::' || x === '::1') return true; const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(x); if (m) return privateV4(m[1]); return /^(fc|fd|fe8|fe9|fea|feb|ff)/.test(x); }; 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; try { const addrs = isIP(host) ? [{ address: host }] : await resolve(host); if (!addrs.length || addrs.some((a) => privateIp(a.address))) return null; } catch { return null; } return u.href; } // ---- ffmpeg --------------------------------------------------------------- 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, // the frame's mean brightness rides out on stderr (YAVG, 0–255): a clip // 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', '-c:a', 'aac', '-b:a', '128k', '-ac', '2', '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', out]; const run = (args, ms) => { const p = Bun.spawn(['ffmpeg', ...args], { stdout: 'pipe', stderr: 'pipe' }); const timer = setTimeout(() => p.kill(9), ms); return { p, done: p.exited.finally(() => clearTimeout(timer)) }; }; const keyOf = (src) => createHash('sha256').update(src).digest('hex'); const failed = new Map(); // key -> at const failedLately = (k) => { const at = failed.get(k); return at && Date.now() - at < FAIL_TTL; }; const touch = (p) => { try { const n = new Date(); utimesSync(p, n, n); } catch {} }; // a few at a time, the rest wait their turn function limiter(n) { let busy = 0; const q = []; const next = () => { while (busy < n && q.length) { busy++; const { fn, res, rej } = q.shift(); fn().then(res, rej).finally(() => { busy--; next(); }); } }; const go = (fn) => new Promise((res, rej) => { q.push({ fn, res, rej }); next(); }); go.waiting = () => q.length; return go; } // ---- thumbnails ----------------------------------------------------------- const thumbSlots = limiter(THUMB_JOBS); const thumbJobs = new Map(); // Half a second in, unless that frame is (nearly) black — an intro fading // in, a title card on black — then further in, keeping the first frame bright // enough to show what the clip is. A clip shorter than a given point simply // yields nothing there. A clip dark all the way through (a night scene) gets // its brightest frame with the levels stretched, so the tile shows what's in // it rather than a black square. YAVG is limited-range: black is 16. const DARK = 28; async function makeThumb(src, key) { const out = `${THUMBS}/${key}.jpg`; let best = null; const grab = async (at, lift) => { const { p, done } = run(thumbArgs(src, at, lift), 25_000); const [buf, err] = await Promise.all([new Response(p.stdout).arrayBuffer(), new Response(p.stderr).text(), done]); if (p.exitCode !== 0 || buf.byteLength <= 500) return null; return { buf, at, y: Number((/YAVG=([\d.]+)/.exec(err) || [])[1] ?? 255) }; }; for (const at of [0.5, 2, 5, 10, 20, 40, 0]) { if (at === 0 && best) break; // the very start only when nothing later worked const got = await grab(at, false); if (!got) continue; if (!best || got.y > best.y) best = got; if (got.y >= DARK) break; } if (best && best.y < DARK) best = (await grab(best.at, true)) || best; if (best) { await Bun.write(out, best.buf); return out; } 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`; if (existsSync(out)) { touch(out); return out; } if (failedLately(key)) return null; if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeThumb(src, key)).finally(() => thumbJobs.delete(key))); return thumbJobs.get(key); } // ---- transcodes ----------------------------------------------------------- const clipSlots = limiter(CLIP_JOBS); const clipJobs = new Map(); // key -> { work, started, done, ok } const allowance = new Map(), picAllowance = new Map(); // ip -> [times] function allowed(ip, book = allowance, perHour = CLIP_PER_HOUR) { const now = Date.now(), list = (book.get(ip) || []).filter((t) => now - t < 3_600_000); if (list.length >= perHour) { book.set(ip, list); return false; } list.push(now); book.set(ip, list); if (book.size > 5000) book.delete(book.keys().next().value); return true; } function startClip(src, key) { const work = `${WORK}/${key}.mp4`, final = `${CLIPS}/${key}.mp4`; const job = { work, ok: false, finished: false }; let begin; job.started = new Promise((r) => (begin = r)); job.done = clipSlots(async () => { begin(); const { p, done } = run(clipArgs(src, work), 20 * 60_000); await done; if (p.exitCode !== 0 || !existsSync(work) || statSync(work).size < 1000) { failed.set(key, Date.now()); console.log('transcode failed', src, (await new Response(p.stderr).text()).slice(-300)); return; } // the finished file, its index first, for range requests and the cache const fs = run(['-hide_banner', '-loglevel', 'error', '-y', '-i', work, '-c', 'copy', '-movflags', '+faststart', '-f', 'mp4', final + '.part'], 120_000); await fs.done; if (fs.p.exitCode === 0) { renameSync(final + '.part', final); job.ok = true; } else failed.set(key, Date.now()); }).catch(() => failed.set(key, Date.now())).finally(() => { job.finished = true; // readers still tailing the work file finish from it; then it goes setTimeout(() => { clipJobs.delete(key); try { unlinkSync(work); } catch {} }, 10 * 60_000); }); clipJobs.set(key, job); return job; } const sizeOf = (p) => { try { return statSync(p).size; } catch { return 0; } }; // the work file as it grows, to the end of the job function tail(job) { let at = 0, fd = null; return new ReadableStream({ async pull(ctl) { for (;;) { const size = sizeOf(job.work); if (size > at) { if (fd == null) fd = openSync(job.work, 'r'); const buf = new Uint8Array(Math.min(size - at, 1 << 20)); const n = readSync(fd, buf, 0, buf.length, at); at += n; ctl.enqueue(buf.subarray(0, n)); return; } if (job.finished) { if (fd != null) closeSync(fd); ctl.close(); return; } await Bun.sleep(200); } }, cancel() { if (fd != null) try { closeSync(fd); } catch {} }, }); } // ---- 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) { const file = Bun.file(path), size = file.size; const base = { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': WEEK, 'Access-Control-Allow-Origin': '*' }; const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') || ''); if (m && (m[1] || m[2])) { let start = m[1] ? +m[1] : Math.max(0, size - +m[2]); let end = m[1] && m[2] ? Math.min(+m[2], size - 1) : size - 1; if (start >= size || start > end) return new Response(null, { status: 416, headers: { ...base, 'Content-Range': `bytes */${size}` } }); return new Response(req.method === 'HEAD' ? null : file.slice(start, end + 1), { status: 206, headers: { ...base, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': String(end - start + 1) } }); } return new Response(req.method === 'HEAD' ? null : file, { headers: { ...base, 'Content-Length': String(size) } }); } const nope = (status, text) => new Response(text, { status, headers: { 'Cache-Control': status === 404 ? 'public, max-age=3600' : 'no-store', 'Access-Control-Allow-Origin': '*' } }); async function clipResponse(req, src, ip) { const key = keyOf(src), final = `${CLIPS}/${key}.mp4`; if (existsSync(final)) { touch(final); return fileResponse(req, final, 'video/mp4'); } if (failedLately(key)) return nope(404, 'Not playable'); let job = clipJobs.get(key); if (job && job.finished && job.ok) return fileResponse(req, final, 'video/mp4'); if (job && job.finished) return nope(404, 'Not playable'); if (!job) { if (clipSlots.waiting() >= CLIP_QUEUE) return nope(503, 'Busy'); if (!allowed(ip)) return nope(429, 'Too many'); job = startClip(src, key); } // answer once there's something to play (or nothing ever will be) await job.started; const t0 = Date.now(); while (!job.finished && sizeOf(job.work) < 64_000 && Date.now() - t0 < 60_000) await Bun.sleep(150); if (job.finished && job.ok && existsSync(final)) return fileResponse(req, final, 'video/mp4'); if (sizeOf(job.work) === 0) return nope(job.finished ? 404 : 503, 'Not playable'); return new Response(req.method === 'HEAD' ? null : tail(job), { headers: { 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' } }); } // ---- pictures --------------------------------------------------------------- const SHAPES = { col: ['--size', '1100x>'], // the 560px column at 2x; never enlarged tile: ['--size', '360x360>', '--smartcrop', 'centre'], // a 120–180px grid tile at 2–3x }; const PIC_V = 1; // what the bytes say the file is (the header is often octet-stream, sometimes a lie) export function pictureKind(b) { const at = (i, str) => b.length >= i + str.length && String.fromCharCode(...b.subarray(i, i + str.length)) === str; if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'; if (at(1, 'PNG') && b[0] === 0x89) return 'png'; if (at(0, 'GIF8')) return 'gif'; if (at(0, 'RIFF') && at(8, 'WEBP')) return 'webp'; if (at(4, 'ftyp')) return 'heif'; // avif, heic if (at(0, 'BM')) return 'bmp'; if (at(0, 'II*\0') || at(0, 'MM\0*')) return 'tiff'; return null; } // the picture's bytes, each redirect's host checked like the first, capped export async function fetchPicture(src, fetchFn = fetch) { let url = src; for (let hop = 0; hop < 5; hop++) { const res = await fetchFn(url, { redirect: 'manual', signal: AbortSignal.timeout(PIC_FETCH_MS), headers: { 'User-Agent': 'coinos-vmedia', Accept: 'image/*,*/*;q=0.5' } }); if ([301, 302, 303, 307, 308].includes(res.status)) { try { await res.body?.cancel(); } catch {} let to = null; try { to = await sourceOk(new URL(res.headers.get('location') || '', url).href); } catch {} if (!to) return null; url = to; continue; } if (!res.ok || !res.body) return null; if (+(res.headers.get('content-length') || 0) > PIC_BYTES) { try { await res.body.cancel(); } catch {} return null; } const chunks = []; let total = 0; const reader = res.body.getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; total += value.byteLength; if (total > PIC_BYTES) { try { await reader.cancel(); } catch {} return null; } chunks.push(value); } return Buffer.concat(chunks); } return null; } const picSlots = limiter(PIC_JOBS); const picJobs = new Map(); async function makePic(src, shape, key) { const out = `${PICS}/${key}.webp`, work = `${WORK}/${key}.src`, part = `${WORK}/${key}.webp`; try { const bytes = await fetchPicture(src).catch(() => null); const kind = bytes && pictureKind(bytes); // a GIF is left alone: the copy would be its first frame, still if (!kind || kind === 'gif') { failed.set(key, Date.now()); return null; } await Bun.write(work, bytes); const p = Bun.spawn(['vipsthumbnail', work, ...SHAPES[shape], '--export-profile', 'srgb', '-o', part + '[Q=82,strip]'], { stdout: 'ignore', stderr: 'pipe', env: { ...process.env, VIPS_CONCURRENCY: '2', VIPS_WARNING: '0' } }); const timer = setTimeout(() => p.kill(9), PIC_MAKE_MS); await p.exited.finally(() => clearTimeout(timer)); if (p.exitCode !== 0 || sizeOf(part) < 50) { console.log('pic failed', src, (await new Response(p.stderr).text()).slice(-300)); failed.set(key, Date.now()); return null; } renameSync(part, out); return out; } finally { for (const f of [work, part]) try { unlinkSync(f); } catch {} } } async function picResponse(req, src, shape, ip) { const key = keyOf('p' + PIC_V + '|' + shape + '|' + src), out = `${PICS}/${key}.webp`; if (existsSync(out)) { touch(out); return fileResponse(req, out, 'image/webp'); } if (failedLately(key)) return nope(404, 'No picture'); if (!picJobs.has(key)) { if (picSlots.waiting() >= PIC_QUEUE) return nope(503, 'Busy'); if (!allowed(ip, picAllowance, PIC_PER_HOUR)) return nope(429, 'Too many'); picJobs.set(key, picSlots(() => makePic(src, shape, key)).catch(() => null).finally(() => picJobs.delete(key))); } const p = await picJobs.get(key); return p ? fileResponse(req, p, 'image/webp') : nope(404, 'No picture'); } // ---- the caches stay a size ------------------------------------------------ function trim(dir, cap) { try { const files = readdirSync(dir).filter((f) => !f.endsWith('.part')).map((f) => { const p = dir + '/' + f, s = statSync(p); return { p, size: s.size, at: s.mtimeMs }; }).sort((a, b) => a.at - b.at); let total = files.reduce((n, f) => n + f.size, 0); 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); 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 {} } 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'); 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' || 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); } if (import.meta.main) { startTrim(); const server = Bun.serve({ hostname: process.env.HOST || '0.0.0.0', port: Number(process.env.PORT || 8801), idleTimeout: 0, fetch: handle }); console.log('vmedia on ' + server.url); }