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
|
// 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.
//
// 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', 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 CLIP_PER_HOUR = 12; // new transcodes per address
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(); // 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);
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': '*' } });
}
// ---- 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); };
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') 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 === '/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);
}
|