1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
|
// Video help for the feed, served at v3.coinos.io/vmedia/ (nginx → this
// container, which carries ffmpeg):
//
// /vmedia/thumb.jpg?u=<clip> 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/h264.mp4?u=<clip> 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=<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,
// and both caches trimmed to a size.
import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync } 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 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
export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
let u;
try { u = new URL(raw); } catch { return null; }
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', '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'];
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;
}
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 {} },
});
}
// ---- 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 {}
}
export function startTrim() {
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
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/, '');
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');
}
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);
}
|