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
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
|
// 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/first.jpg?u=<clip> 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=<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.
// /vmedia/hls.m3u8?u=<clip> 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);
}
|