aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js68
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css8
-rw-r--r--tools/video-fallback-test.js120
-rw-r--r--tools/video-unmute-test.js1
-rw-r--r--vmedia/Dockerfile8
-rw-r--r--vmedia/server.js250
7 files changed, 448 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 4f12812..12d928e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5847,7 +5847,10 @@ export function messagesFeature(ctx) {
const tw = u32(end - 8) >>> 16, th = u32(end - 4) >>> 16;
if (tw && th) { w = tw; h = th; }
}
- return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}) };
+ // an iPhone's HEVC (hvc1/hev1 in the sample description)
+ let hevc = false;
+ for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
+ return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
@@ -5901,7 +5904,7 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}) };
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
@@ -6738,21 +6741,67 @@ export function messagesFeature(ctx) {
cover.remove();
onPlay && onPlay();
const v = box.querySelector('video');
- if (v) { if (!v.getAttribute('src')) v.setAttribute('src', url); v.preload = 'auto'; v.play().catch(() => {}); }
+ if (v) { if (!v.getAttribute('src')) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; v.play().catch(() => {}); }
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
}
+ // ---- clips this browser can't picture, posters a post didn't give -------
+ // The server (vmedia/server.js) makes a still for a clip that came without
+ // one — a clip waiting for a tap was a black box — and an H.264 copy of
+ // one whose picture this browser can't decode: an iPhone's HEVC on Chrome
+ // for Linux plays its sound over black.
+ const VMEDIA = APP_BASE + '/vmedia/';
+ const vmediaOk = (url) => /^https:\/\//i.test(url || '');
+ const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?u=' + encodeURIComponent(url) : undefined);
+ const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
+ const noPicture = new Set(), h264Failed = new Set();
+ let hevcPlays;
+ const canHevc = () => hevcPlays ??= (() => {
+ try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
+ })();
+ // what a player for this clip loads: the copy, once the original is known
+ // not to show here
+ const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
+ && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A player whose clip turns out to have no picture it can show (its
+ // metadata says zero width) switches to the copy; if the copy fails too,
+ // the original's sound plays under a line saying why, with the file.
+ function guardPicture(v, url) {
+ if (!vmediaOk(url)) return;
+ const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA);
+ v.addEventListener('loadedmetadata', () => {
+ if (v.videoWidth || onCopy() || h264Failed.has(url)) return;
+ noPicture.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', h264Of(url));
+ if (want) v.play().catch(() => {});
+ });
+ v.addEventListener('error', () => {
+ if (!onCopy()) return;
+ h264Failed.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', url);
+ if (want) v.play().catch(() => {});
+ const box = v.parentElement;
+ if (box && !box.querySelector('.vid-nopic')) {
+ box._skipMorph = true;
+ box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoNoPicture')),
+ h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile'))));
+ }
+ });
+ }
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
// Nothing is fetched until the clip is about to play (preload none): a
// window of rows used to pull every clip's metadata, which for a file
// with its index at the end can be most of the file.
- const v = h('video', { src: url, class: 'note-video', controls: true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || undefined,
+ const v = h('video', { src: playSrc(url), class: 'note-video', controls: true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || serverThumb(url),
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
- onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
+ onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
+ guardPicture(v, url);
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
const box = h('div', { class: 'note-video-box' }, v);
if (loud) box._skipMorph = true;
@@ -6782,7 +6831,7 @@ export function messagesFeature(ctx) {
box._inView = true;
clearTimeout(box._release);
if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) {
- v.setAttribute('src', url);
+ v.setAttribute('src', playSrc(url));
v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
}
// a clip over the budget waits for a tap, with what it costs on it
@@ -10627,7 +10676,7 @@ export function messagesFeature(ctx) {
const video = pager.kind === 'videos';
const media = video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
- poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || undefined })
+ poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) })
: h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' });
const caption = mediaCaption(ev);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
@@ -10659,6 +10708,7 @@ export function messagesFeature(ctx) {
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
slide._ev = ev;
if (video) {
+ guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
}
@@ -10666,7 +10716,7 @@ export function messagesFeature(ctx) {
}
function pagerAttach(slide) {
const m = slide.querySelector('.mp-media');
- if (m && !m.getAttribute('src')) m.setAttribute('src', m.getAttribute('data-src'));
+ if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src'));
}
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
@@ -10741,7 +10791,7 @@ export function messagesFeature(ctx) {
}
function startSlide(slide) {
const v = slide.querySelector('video.mp-media');
- if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', v.getAttribute('data-src')); // the still's src: from the start now
+ if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', playSrc(v.getAttribute('data-src'))); // the still's src: from the start now
v.preload = 'auto';
pagerAttach(slide);
v.muted = !pagerLoud;
diff --git a/src/i18n.js b/src/i18n.js
index 425b059..61a97dd 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -258,6 +258,8 @@ const STR = {
playVideo: 'Play',
videoUnmute: 'Unmute',
videoTapToPlay: "Tap to play",
+ videoNoPicture: "This browser can’t show this video’s picture",
+ videoOpenFile: "Open the file",
videoLargeTitle: "Videos",
videoLargeToggle: "Autoplay large videos",
videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.",
diff --git a/src/style.css b/src/style.css
index 5acd9ea..7fb4c0a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1494,6 +1494,14 @@ body::after {
display: flex; align-items: center; justify-content: center; font-size: 22px; padding-left: 4px;
}
.note-video-box .vid-heavy { min-height: 120px; }
+/* a clip whose picture this browser can't decode, even as the server's copy:
+ its sound plays, this says why the box is black */
+.vid-nopic {
+ position: absolute; left: 0; right: 0; top: 40%; z-index: 2; padding: 0 16px;
+ display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none;
+ color: #fff; font-size: 14px; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
+}
+.vid-nopic a { color: #fff; text-decoration: underline; pointer-events: auto; }
.media-tile-cost {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: rgba(255, 255, 255, .8); font-size: 12px; font-weight: 600; background: #16181d;
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
new file mode 100644
index 0000000..81c22d3
--- /dev/null
+++ b/tools/video-fallback-test.js
@@ -0,0 +1,120 @@
+// A clip this browser can't picture (an iPhone's HEVC on Chrome for Linux
+// played its sound over black) switches to the server's H.264 copy; if the
+// copy fails too, the sound plays under a line saying why. A clip whose
+// post gave no poster wears the server's still. The server (vmedia) is
+// stood in for here: its answers are canned.
+// Run: bun tools/video-fallback-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';
+import { readFileSync } from 'node:fs';
+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 DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const HEVC = ff(DIR + '/coinos-fallback-hevc.mp4', ['-c:v', 'libx265', '-tag:v', 'hvc1', '-x265-params', 'log-level=error']);
+const H264 = ff(DIR + '/coinos-fallback-h264.mp4', ['-c:v', 'libx264']);
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+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 CLIP = { saved: 'https://media.test/saved.mp4', lost: 'https://media.test/lost.mp4', known: 'https://media.test/known.mp4' };
+const seeded = [post('garden ' + CLIP.saved, 100), post('broken ' + CLIP.lost, 200), post('probed ' + CLIP.known, 300)];
+
+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', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip 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, src]);
+ if (u.pathname === '/vmedia/thumb.jpg') return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (src === CLIP.lost) return r.respond({ status: 404, body: 'Not playable' });
+ return r.respond({ status: 200, contentType: 'video/mp4', body: H264, headers: { 'access-control-allow-origin': '*' } });
+ }
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the probe is answered for one clip only: the others are found out by playing
+ if (r.resourceType() === 'fetch' && r.url() !== CLIP.known) return r.abort();
+ return r.respond({ status: 200, contentType: 'video/mp4', body: HEVC, headers: { 'access-control-allow-origin': '*' } });
+ }
+ 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 box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+try {
+ const hevcHere = await page.evaluate(() => document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'));
+ if (hevcHere) console.log(' (this Chrome decodes HEVC; the clip-switch checks can\'t fail here)');
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ 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: 'Clip 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 clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 3, null, 20000));
+
+ console.log('\n[no poster in the post]');
+ const p0 = await player('garden');
+ check('the server\'s still is the poster', p0.poster === 'https://v3.coinos.io/vmedia/thumb.jpg?u=' + encodeURIComponent(CLIP.saved), p0.poster);
+
+ console.log('\n[found out by playing]');
+ await show('garden');
+ check('the clip switches to the H.264 copy and shows its picture', await waitFor(new Function(`const v = ${box('garden')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/h264.mp4') && v.videoWidth > 0;`), null, 10000),
+ JSON.stringify(await player('garden')));
+ check('the original was tried first', asked.some(([k, u]) => k === 'media' && u === CLIP.saved));
+
+ console.log('\n[the copy fails too]');
+ await show('broken');
+ check('its sound plays under a line saying why, with the file', await waitFor(new Function(`const b = ${box('broken')}; const v = b && b.querySelector('video'); return v && b.querySelector('.vid-nopic') && v.getAttribute('src') === '${CLIP.lost}';`), null, 10000),
+ JSON.stringify(await player('broken')));
+ const p1 = await player('broken');
+ check('…which says the browser can\'t show it', /can.t show/.test(p1.note) && /Open the file/.test(p1.note), p1.note);
+
+ console.log('\n[known from the probe]');
+ await sleep(500);
+ await show('probed');
+ check('an HEVC header seen by the probe plays the copy from the start', await waitFor(new Function(`const v = ${box('probed')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/h264.mp4') && v.videoWidth > 0;`), null, 10000),
+ JSON.stringify(await player('probed')));
+ check('…without the player fetching the original', !asked.some(([k, u]) => k === 'media' && u === CLIP.known), JSON.stringify(asked.filter(([, u]) => u === CLIP.known)));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ clips without a picture here get the copy, or say why' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-unmute-test.js b/tools/video-unmute-test.js
index ac7cfc4..6aef88e 100644
--- a/tools/video-unmute-test.js
+++ b/tools/video-unmute-test.js
@@ -20,6 +20,7 @@ const server = Bun.serve({ port: 5302, fetch(req) {
// and no clip over the data budget
const videoThumbs = new Map(), videoInfo = new Map(), tappedClips = new Set();
const feedMedia = () => null, rememberVideo = () => {}, probeVideo = async () => {}, videoHeavy = () => false, heavyLabel = () => '';
+ const APP_BASE = 'https://v3.coinos.io';
${dom}
${video}
const url = '/clip.webm';
diff --git a/vmedia/Dockerfile b/vmedia/Dockerfile
new file mode 100644
index 0000000..336ddff
--- /dev/null
+++ b/vmedia/Dockerfile
@@ -0,0 +1,8 @@
+# vmedia: bun + ffmpeg. 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/*
+WORKDIR /app
+CMD ["bun", "server.js"]
diff --git a/vmedia/server.js b/vmedia/server.js
new file mode 100644
index 0000000..d1b7a8a
--- /dev/null
+++ b/vmedia/server.js
@@ -0,0 +1,250 @@
+// 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 = 4, 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) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src,
+ '-frames:v', '1', '-vf', "scale='min(480,iw)':-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();
+async function makeThumb(src, key) {
+ const out = `${THUMBS}/${key}.jpg`;
+ for (const at of [0.5, 0]) { // a clip shorter than half a second has only its start
+ const { p, done } = run(thumbArgs(src, at), 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;
+}
+async function thumb(src) {
+ const key = keyOf(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);
+}