diff options
Diffstat (limited to 'vmedia/server.js')
| -rw-r--r-- | vmedia/server.js | 123 |
1 files changed, 111 insertions, 12 deletions
diff --git a/vmedia/server.js b/vmedia/server.js index c543d6c..b2f5bae 100644 --- a/vmedia/server.js +++ b/vmedia/server.js @@ -9,6 +9,14 @@ // 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=<picture> 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 <img> 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. // // 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, @@ -19,11 +27,13 @@ import { isIP } from 'node:net'; import { createHash } from 'node:crypto'; const DATA = process.env.DATA || '/data'; -const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', WORK = DATA + '/work'; -for (const d of [THUMBS, CLIPS, WORK]) mkdirSync(d, { recursive: true }); -const THUMB_CAP = 2e9, CLIP_CAP = 25e9; -const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6; +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 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; @@ -133,12 +143,12 @@ async function thumb(src) { // ---- transcodes ----------------------------------------------------------- const clipSlots = limiter(CLIP_JOBS); const clipJobs = new Map(); // key -> { work, started, done, ok } -const allowance = new Map(); // ip -> [times] -function allowed(ip) { - const now = Date.now(), list = (allowance.get(ip) || []).filter((t) => now - t < 3_600_000); - if (list.length >= CLIP_PER_HOUR) { allowance.set(ip, list); return false; } - list.push(now); allowance.set(ip, list); - if (allowance.size > 5000) allowance.delete(allowance.keys().next().value); +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) { @@ -231,6 +241,91 @@ async function clipResponse(req, src, ip) { { 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 { @@ -243,7 +338,7 @@ function trim(dir, cap) { } catch {} } export function startTrim() { - const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); }; + const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); }; go(); setInterval(go, 10 * 60_000); // work left over from a restart is no good to anyone @@ -254,10 +349,14 @@ 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') return nope(404, 'Not found'); + if (route !== '/thumb.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); return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture'); |
