aboutsummaryrefslogtreecommitdiffstats
path: root/vmedia/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'vmedia/server.js')
-rw-r--r--vmedia/server.js123
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');