diff options
| author | Adam Soltys <[email protected]> | 2026-10-11 22:06:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-11 22:06:12 -0700 |
| commit | 8ff0bf21e308d4907cc2620d80f238a10341a28d (patch) | |
| tree | 4e6e6a3995fc4d469313100b69ef9768c330aadc | |
| parent | a34769e3f60aa5b6920e4e2c9e0a9ee1013cacd1 (diff) | |
| download | coinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.tar.gz coinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.zip | |
Feed: a picture from a host that won't hand over its bytes paints from a server-made copy. The app shrinks a post's photo to the column's size itself (an 1100px copy made once, kept on disk) — but only when the host 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. vmedia now has /vmedia/pic.webp?s=col|tile&u=<picture>: the picture at 1100px wide (or a 360px square of its middle for the grid), made with libvips (shrink-on-load, so the original is never decoded whole; turned the way its EXIF says; alpha kept; stripped) as WebP, cached on disk and at the CDN for everyone. The usual guards: https on public addresses only, each redirect's host checked, 60 MB cap, the bytes sniffed (a GIF is refused — its copy would be a still), a few jobs at a time, 600 new copies per address per hour, an 8 GB cache. The client asks the server for the column copy when the host is known to keep its bytes (or has just refused, or there's no worker to make one here), keeps it in the same cache as the copies it makes, and takes the server's tile for the grid the same way; a picture the server has no copy for paints as before. The container gets libvips-tools (Dockerfile). tools/pic-proxy-test.js (canned server answers). Server measured on cs: a 3072×4608 photo in 0.5 s the first time, a millisecond after; feed-stability's two pre-existing asserts still fail on 491904e.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 61 | ||||
| -rw-r--r-- | tools/pic-proxy-test.js | 132 | ||||
| -rw-r--r-- | vmedia/Dockerfile | 4 | ||||
| -rw-r--r-- | vmedia/server.js | 123 |
4 files changed, 302 insertions, 18 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index bb2d788..ef427eb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6152,6 +6152,7 @@ export function messagesFeature(ctx) { const READY_MS = 8000; const mediaReady = new Map(); // URL -> decoded dimensions const mediaFailed = new Set(); // URLs whose picture refused to load (not merely slow) + const corsRefused = new Set(); // pictures whose host wouldn't hand over the bytes (this session) const mediaWarming = new Map(); // A post's photo is often the camera's original — 3024px wide, twelve // megapixels — painted into a column a sixth of that. Chrome decodes it @@ -6185,7 +6186,7 @@ export function messagesFeature(ctx) { if (!res.ok) return null; blob = await res.blob(); } catch (e) { - if (e && e.corsRefused) noteNoCors(url); // refused, as corsFetch confirmed + if (e && e.corsRefused) { noteNoCors(url); corsRefused.add(url); } // refused, as corsFetch confirmed return null; } const result = await makeImageThumbnails(blob, { @@ -6216,7 +6217,10 @@ export function messagesFeature(ctx) { if (mediaWarming.has(url)) return mediaWarming.get(url); if (thumb && /^https?:/i.test(url)) { const task = (async () => { - const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); + let got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); + // no copy to be made here (the host keeps its bytes, or no worker to + // make it): the server makes one + if (!got && (noCorsHost(url) || corsRefused.has(url) || typeof OffscreenCanvas === 'undefined')) got = await serverMediaThumb(url).catch(() => null); if (got) { // no copy (small already, or a GIF): the original is what paints, // so it's the one decoded ahead — the row mustn't decode it on sight @@ -7383,6 +7387,47 @@ export function messagesFeature(ctx) { const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) + // A picture from a host that won't hand over its bytes (no CORS: primal's + // blossom, nostrcheck, most personal sites) can't have its column copy made + // here, so the server makes it (pic.webp: 1100px wide, or a 360px square + // for the grid; libvips, cached for everyone at the CDN). Without this the + // <img> painted the original, and a 9528×6328 camera file froze a wheel + // scroll for 400 ms on its texture upload. The copy is kept in the same + // cache as the ones made here, so the next visit paints it at once. + const serverPic = (url, shape) => (vmediaOk(url) && !/\.gif(\?|$)/i.test(url) ? VMEDIA + 'pic.webp?s=' + shape + '&u=' + encodeURIComponent(url) : null); + const serverPicFailed = new Set(); // pictures the server had no copy for (this session) + const picDims = (src) => new Promise((resolve) => { + const img = new Image(); + img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(() => resolve({ width: img.naturalWidth, height: img.naturalHeight }), () => resolve(null)); + img.onerror = () => resolve(null); + img.src = src; + }); + async function serverPicBlob(url, shape) { + const src = serverPic(url, shape); + if (!src || serverPicFailed.has(shape + '|' + url)) return null; + let res; + try { res = await fetch(src, { signal: AbortSignal.timeout(READY_MS) }); } catch { return null; } + if (!res.ok) { + if (res.status !== 429 && res.status !== 503) serverPicFailed.add(shape + '|' + url); // busy is tried again + return null; + } + const blob = await res.blob(); + return /^image\//.test(blob.type) && blob.size ? blob : null; + } + async function serverMediaThumb(url) { + const blob = await serverPicBlob(url, 'col'); + if (!blob) return null; + const thumb = URL.createObjectURL(blob); + const dims = await picDims(thumb); + if (!dims || !dims.width) { URL.revokeObjectURL(thumb); serverPicFailed.add('col|' + url); return null; } + try { + const store = await thumbStore(); + await store.put(url, new Response(blob, { headers: { 'x-dims': JSON.stringify(dims), 'x-original': '0' } })); + const keys = await store.keys(); + for (const k of keys.slice(0, Math.max(0, keys.length - MEDIA_THUMB_MAX))) store.delete(k); + } catch {} + return { ...dims, thumb }; + } let hevcPlays; const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } @@ -11388,13 +11433,21 @@ export function messagesFeature(ctx) { if (typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof caches === 'undefined') return; // the column copy is already here and smaller than the original const kept = mediaReady.get(url)?.thumb; - if (!kept && noCorsHost(url)) return; + if (!kept && (noCorsHost(url) || corsRefused.has(url))) { + // the host keeps its bytes: the server cuts the tile + const tile = await serverPicBlob(url, 'tile'); + if (tile) await saveTileThumb(url, tile); + return; + } let blob; try { const res = kept ? await fetch(kept) : await corsFetch(url, AbortSignal.timeout(READY_MS)); if (!res.ok) return; blob = await res.blob(); - } catch { return; } + } catch (e) { + if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); } + return; + } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original const result = await makeImageThumbnails(blob, { tileSize: TILE_PX }); if (result?.tile) await saveTileThumb(url, result.tile); diff --git a/tools/pic-proxy-test.js b/tools/pic-proxy-test.js new file mode 100644 index 0000000..881867c --- /dev/null +++ b/tools/pic-proxy-test.js @@ -0,0 +1,132 @@ +// A picture from a host that won't hand over its bytes (no CORS) paints from +// the server's column copy (vmedia pic.webp) instead of the original, and the +// copy is kept in the media-thumb cache for the next visit. A GIF is never +// asked for (the copy would be a still); a picture the server has no copy for +// paints the original as before. The server is stood in for: its answers are +// canned. +// Run: bun tools/pic-proxy-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const still = (args) => { + const p = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + args.size, '-frames:v', '1', '-f', 'image2', '-c:v', args.codec, 'pipe:1']); + if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); } + return p.stdout; +}; +const ORIGINAL = still({ size: '640x480', codec: 'mjpeg' }); // stands in for the camera original +const COPY = still({ size: '320x240', codec: 'libwebp' }); // the server's column copy +const GIF = still({ size: '64x64', codec: 'gif' }); + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const PIC = { copied: 'https://pics.test/camera.jpg', gif: 'https://pics.test/loop.gif', lost: 'https://pics.test/odd.png' }; +const seeded = [post('garden ' + PIC.copied, 100), post('loop ' + PIC.gif, 200), post('broken ' + PIC.lost, 300)]; +const PIC_URL = (u, s = 'col') => 'https://v3.coinos.io/vmedia/pic.webp?s=' + s + '&u=' + encodeURIComponent(u); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setRequestInterception(true); +const asked = []; // [kind, url] +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) { + const src = u.searchParams.get('u'); + asked.push([u.pathname + '?s=' + u.searchParams.get('s'), src]); + if (src === PIC.lost) return r.respond({ status: 404, body: 'No picture' }); + return r.respond({ status: 200, contentType: 'image/webp', body: COPY, headers: { 'cache-control': 'public, max-age=604800', 'access-control-allow-origin': '*' } }); + } + if (u.host === 'pics.test') { + asked.push([r.resourceType(), r.url()]); + // no CORS header: a fetch from the page is refused, an <img> paints it + if (r.url() === PIC.gif) return r.respond({ status: 200, contentType: 'image/gif', body: GIF }); + return r.respond({ status: 200, contentType: r.url().endsWith('.png') ? 'image/png' : 'image/jpeg', body: ORIGINAL }); + } + r.continue(); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +const pic = (word) => page.evaluate((w) => { + const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.textContent.includes(w)); + const img = row && row.querySelector('img.note-img'); + return img && { src: img.getAttribute('src'), w: img.naturalWidth, h: img.naturalHeight, late: img.classList.contains('note-img-late'), ar: img.style.aspectRatio }; +}, word); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + // the host is already known to keep its bytes (two refusals, a week ago) + localStorage.setItem('coinos-thumb-nocors-v5', JSON.stringify({ 'pics.test': { n: 2, until: Date.now() + 86400000 } })); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the three pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000)); + + console.log('\n[a host that keeps its bytes]'); + // the cached rows paint at once (the original, lazily); the prepared paint + // that follows the warm swaps the copy in + await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].some((i) => i.getAttribute('src').startsWith('blob:')), null, 15000); + const p0 = await pic('garden'); + check('the row paints the server\'s copy, not the original', p0 && p0.src.startsWith('blob:') && p0.w === 320, JSON.stringify(p0)); + check('…with the box shaped like the picture', p0 && /^(320 \/ 240|4 \/ 3)$/.test(p0.ar), p0 && p0.ar); + check('the server was asked for the column copy', asked.some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied)); + check('the original was never fetched by script', !asked.some(([k, u]) => k === 'fetch' && u === PIC.copied), JSON.stringify(asked.filter(([, u]) => u === PIC.copied))); + const kept = await page.evaluate(async (u) => { + const r = await (await caches.open('coinos-media-thumbs-v1')).match(u); + return r && { dims: r.headers.get('x-dims'), original: r.headers.get('x-original'), type: (await r.blob()).type }; + }, PIC.copied); + check('the copy is kept for the next visit', kept && kept.original === '0' && kept.type === 'image/webp' && /320/.test(kept.dims), JSON.stringify(kept)); + + console.log('\n[a GIF]'); + const p1 = await pic('loop'); + check('a GIF paints the original (a copy would be a still)', p1 && p1.src === PIC.gif, JSON.stringify(p1)); + check('…and the server was not asked', !asked.some(([k, u]) => k.startsWith('/vmedia/pic.webp') && u === PIC.gif)); + + console.log('\n[the server has no copy]'); + const p2 = await pic('broken'); + check('the original paints as before', p2 && p2.src === PIC.lost && p2.w === 640, JSON.stringify(p2)); + check('the server was asked once', asked.filter(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.lost).length === 1); + + console.log('\n[the next visit]'); + const before = asked.length; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000); + const p3 = await pic('garden'); + check('the kept copy paints without asking the server again', p3 && p3.src.startsWith('blob:') && p3.w === 320 + && !asked.slice(before).some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied), JSON.stringify(p3)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ pictures from hosts without CORS paint from the server\'s copy' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/vmedia/Dockerfile b/vmedia/Dockerfile index 336ddff..3552f5f 100644 --- a/vmedia/Dockerfile +++ b/vmedia/Dockerfile @@ -1,8 +1,8 @@ -# vmedia: bun + ffmpeg. Built and run on cs (see vmedia/server.js): +# vmedia: bun + ffmpeg + libvips. Built and run on cs (see vmedia/server.js): # docker build -t vmedia vmedia/ # docker run -d --name vmedia --network net --restart unless-stopped --cpus 6 --memory 3g \ # -v ~/vmedia/server.js:/app/server.js:ro -v ~/vmedia/data:/data vmedia FROM oven/bun:1 -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libvips-tools ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app CMD ["bun", "server.js"] 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'); |
