perf(video): stream a capped, faststart rendition instead of the raw upload
THE ANSWER to "why does an already-downloaded video still buffer?" — it was never the download, and it was not the server. Probing the real uploads on the box: d0e49e58… 1920x1080 19.4 Mbps 75 MB / 31 s ad929d0b… 1920x1080 19.0 Mbps 27 MB / 11 s 9f4e0865… 720x1584 3.6 Mbps 14 MB / 31 s To play a 19 Mbps file the client has to SUSTAIN a 19 Mbps download for the whole clip. No mobile link does, so the <video> buffer drains every few seconds: buffers, plays, buffers, plays. Server-side disk read was instant and load was 1.7 on 20 cores throughout — the bottleneck is the media itself, not the delivery path. Second, independent defect: phone MP4s store `moov` AFTER `mdat` (verified on two uploads), so the player must fetch the file's tail before it can start at all. Fix — keep the original bytes untouched (that is what the download button serves, full quality) and build <id>.web.mp4 beside it: longest side capped at 1280, ~2.5 Mbps ceiling, +faststart. Measured on the 19 Mbps file: 27.3 MB @ 19.0 Mbps -> 2.55 MB @ 1.78 Mbps (10.7x less bandwidth) transcode took 2.4 s for an 11.5 s clip - server/media.js (new): probe, decide, 2-at-a-time background queue. Already light + correctly sized + faststart => no rendition at all. Light but wrong atom order => remux -c copy (seconds, no re-encode). Otherwise re-encode. A rendition that lands bigger than the original is discarded. MP4 box-walker for the faststart test is unit-checked against known fast/slow files, both directions. - /stream/<id> serves the rendition, falling back to the original while it is still transcoding, so a video is never unplayable. /files/<id> is unchanged and still serves the pristine original for download. - Renditions are queued at upload, and backfilled 15 s after boot for the videos that predate this. Range serving is now one shared helper for both routes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
//
|
||||
// media.js — web playback renditions for uploaded videos.
|
||||
//
|
||||
// WHY THIS EXISTS (measured, not guessed):
|
||||
// Real uploads on this box were probed at 19.4 Mbps and 19.0 Mbps (1080p screen recordings) and
|
||||
// 3.6 Mbps (phone portrait). To play a 19 Mbps file the client must SUSTAIN a 19 Mbps download for
|
||||
// the whole clip; no mobile link does, so the <video> buffer drains every few seconds and you get
|
||||
// the classic "buffers, plays, buffers, plays". Server disk and CPU were idle throughout — the
|
||||
// bottleneck is the media, not the delivery.
|
||||
// Separately, phone MP4s often store `moov` AFTER `mdat` (not faststart), so the player has to
|
||||
// fetch the tail before it can begin at all.
|
||||
//
|
||||
// WHAT WE DO:
|
||||
// Leave the uploaded bytes untouched — that is what the download button serves, at full quality.
|
||||
// Alongside it build <id>.web.mp4: longest side capped at 1280, ~2.5 Mbps ceiling, +faststart.
|
||||
// /stream/<id> prefers that rendition and falls back to the original until it is ready, so a video
|
||||
// is never unplayable while it transcodes.
|
||||
//
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { UPLOADS_DIR } = require('./config');
|
||||
|
||||
const MAX_CONCURRENT = 2; // transcoding is never urgent; leave the cores to the app
|
||||
const OK_BITRATE = 2500000; // ≤2.5 Mbps streams fine over mobile
|
||||
const OK_DIMENSION = 1280; // ...provided it isn't oversized as well
|
||||
const PROBE_TIMEOUT = 20000;
|
||||
const XCODE_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
const pending = new Set(); // ids queued or transcoding right now
|
||||
const failed = new Set(); // ffmpeg couldn't handle it — don't retry forever
|
||||
|
||||
const webPath = (id) => path.join(UPLOADS_DIR, id + '.web.mp4');
|
||||
|
||||
function hasWebRendition(id) {
|
||||
try { return fs.statSync(webPath(id)).size > 0; } catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Walk the top-level MP4 box headers. If `mdat` comes before `moov` the index lives at the end of
|
||||
// the file and the player must seek there before it can start — that is what +faststart fixes.
|
||||
function isFastStart(fp) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(fp, 'r');
|
||||
const buf = Buffer.alloc(4096);
|
||||
const read = fs.readSync(fd, buf, 0, 4096, 0);
|
||||
let off = 0;
|
||||
while (off + 8 <= read) {
|
||||
let size = buf.readUInt32BE(off);
|
||||
const type = buf.toString('latin1', off + 4, off + 8);
|
||||
if (type === 'moov') return true;
|
||||
if (type === 'mdat') return false;
|
||||
if (size === 1) { // 64-bit largesize follows the header
|
||||
if (off + 16 > read) return true;
|
||||
size = Number(buf.readBigUInt64BE(off + 8));
|
||||
} else if (size === 0) return true; // box runs to EOF
|
||||
if (size < 8) return true; // malformed — leave it alone
|
||||
off += size;
|
||||
}
|
||||
return true; // couldn't tell; assume fine
|
||||
} catch (e) {
|
||||
return true;
|
||||
} finally {
|
||||
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
||||
}
|
||||
}
|
||||
|
||||
function probe(fp, cb) {
|
||||
execFile('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height,codec_name',
|
||||
'-show_entries', 'format=bit_rate,duration',
|
||||
'-of', 'json', fp], { timeout: PROBE_TIMEOUT, maxBuffer: 1 << 20 }, (err, stdout) => {
|
||||
if (err) return cb(err);
|
||||
let info;
|
||||
try { info = JSON.parse(stdout); } catch (e) { return cb(e); }
|
||||
const s = (info.streams || [])[0], f = info.format || {};
|
||||
if (!s || !s.width) return cb(new Error('no video stream'));
|
||||
cb(null, {
|
||||
width: +s.width, height: +s.height, codec: s.codec_name || '',
|
||||
bitrate: +f.bit_rate || 0, duration: +f.duration || 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Cap the LONGEST side at 1280 (so 1920x1080 → 1280x720, and portrait 1080x2340 → 591x1280) while
|
||||
// preserving aspect. force_divisible_by=2 keeps dimensions legal for H.264.
|
||||
const SCALE = 'scale=w=' + OK_DIMENSION + ':h=' + OK_DIMENSION +
|
||||
':force_original_aspect_ratio=decrease:force_divisible_by=2';
|
||||
|
||||
function buildArgs(src, out, meta) {
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
const lightEnough = meta.bitrate > 0 && meta.bitrate <= OK_BITRATE && maxDim <= OK_DIMENSION;
|
||||
// Already small enough and only the atom order is wrong → remux, no re-encode. Seconds, not minutes.
|
||||
if (lightEnough && /^(h264|avc1)$/i.test(meta.codec)) {
|
||||
return ['-y', '-i', src, '-c', 'copy', '-movflags', '+faststart', '-f', 'mp4', out];
|
||||
}
|
||||
return ['-y', '-i', src,
|
||||
'-map', '0:v:0', '-map', '0:a:0?', // audio optional — silent clips exist
|
||||
'-vf', SCALE,
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-level', '4.0',
|
||||
'-crf', '24', '-maxrate', '2500k', '-bufsize', '5000k',
|
||||
'-g', '48', '-pix_fmt', 'yuv420p', // 2s keyframes: smooth seeking
|
||||
'-c:a', 'aac', '-b:a', '128k', '-ac', '2',
|
||||
'-movflags', '+faststart', '-f', 'mp4', out];
|
||||
}
|
||||
|
||||
function pump() {
|
||||
while (running < MAX_CONCURRENT && queue.length) {
|
||||
const id = queue.shift();
|
||||
running++;
|
||||
transcode(id, () => { running--; pending.delete(id); pump(); });
|
||||
}
|
||||
}
|
||||
|
||||
function transcode(id, done) {
|
||||
const src = path.join(UPLOADS_DIR, id);
|
||||
const out = webPath(id);
|
||||
const tmp = out + '.part';
|
||||
if (!src.startsWith(UPLOADS_DIR)) return done();
|
||||
fs.stat(src, (e, srcStat) => {
|
||||
if (e) return done();
|
||||
probe(src, (perr, meta) => {
|
||||
if (perr) { failed.add(id); return done(); }
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
// Nothing to gain: already light, already sized, already faststart.
|
||||
if (meta.bitrate > 0 && meta.bitrate <= OK_BITRATE && maxDim <= OK_DIMENSION && isFastStart(src)) {
|
||||
failed.add(id); // "no rendition needed" — same effect: stop reconsidering it
|
||||
return done();
|
||||
}
|
||||
execFile('ffmpeg', buildArgs(src, tmp, meta), { timeout: XCODE_TIMEOUT, maxBuffer: 1 << 20 }, (xerr) => {
|
||||
if (xerr) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
failed.add(id);
|
||||
return done();
|
||||
}
|
||||
let outStat;
|
||||
try { outStat = fs.statSync(tmp); } catch (_) { failed.add(id); return done(); }
|
||||
// A rendition bigger than the original helps nobody — throw it away and stream the original.
|
||||
if (!outStat.size || outStat.size >= srcStat.size) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
failed.add(id);
|
||||
return done();
|
||||
}
|
||||
try { fs.renameSync(tmp, out); } catch (_) { try { fs.unlinkSync(tmp); } catch (__) {} }
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Queue a freshly uploaded (or first-played) video. Cheap and idempotent: safe to call on every
|
||||
// /stream hit, which is also how pre-existing uploads get backfilled.
|
||||
function ensureWebRendition(id, mime) {
|
||||
if (!/^video\//.test(mime || '')) return;
|
||||
if (pending.has(id) || failed.has(id) || hasWebRendition(id)) return;
|
||||
pending.add(id);
|
||||
queue.push(id);
|
||||
pump();
|
||||
}
|
||||
|
||||
// Drop derived files when the attachment goes away.
|
||||
function dropDerived(id) {
|
||||
for (const p of [webPath(id), webPath(id) + '.part', path.join(UPLOADS_DIR, id + '.thumb.jpg')]) {
|
||||
try { fs.unlinkSync(p); } catch (e) {}
|
||||
}
|
||||
pending.delete(id); failed.delete(id);
|
||||
}
|
||||
|
||||
// One-off catch-up for videos uploaded before this module existed (and after a restore). Renditions
|
||||
// persist on the data volume, so on a normal restart this finds nothing to do and costs one query.
|
||||
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
|
||||
function backfill() {
|
||||
setTimeout(() => {
|
||||
let rows = [];
|
||||
try { rows = require('./repos').attachments.allVideos(); } catch (e) { return; }
|
||||
let queued = 0;
|
||||
for (const r of rows) {
|
||||
if (hasWebRendition(r.id)) continue;
|
||||
try { if (!fs.statSync(path.join(UPLOADS_DIR, r.id)).size) continue; } catch (e) { continue; }
|
||||
ensureWebRendition(r.id, r.mime); queued++;
|
||||
}
|
||||
if (queued) console.log('[media] backfilling streaming renditions for ' + queued + ' video(s)');
|
||||
}, 15000).unref();
|
||||
}
|
||||
|
||||
module.exports = { ensureWebRendition, hasWebRendition, webPath, dropDerived, backfill };
|
||||
@@ -1165,7 +1165,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-22-batch159';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-23-batch160';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
||||
//
|
||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||
@@ -2013,7 +2013,7 @@ function bubbleHTML(m){
|
||||
const A = m.attachment;
|
||||
const att = A ? (
|
||||
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
|
||||
: A.isVideo ? '<div class="att-vid"><video class="att-vid-v" src="/files/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>'
|
||||
: A.isVideo ? '<div class="att-vid"><video class="att-vid-v" src="/stream/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>'
|
||||
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
|
||||
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
||||
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||||
|
||||
@@ -296,6 +296,8 @@ const attachments = {
|
||||
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, uploaderId, name, mime || null, size || 0, now()),
|
||||
byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id),
|
||||
// Newest first: media.js backfills streaming renditions for uploads that predate it.
|
||||
allVideos: () => db.prepare("SELECT id, mime FROM attachments WHERE mime LIKE 'video/%' ORDER BY created_at DESC").all(),
|
||||
};
|
||||
|
||||
const scheduledMeetings = {
|
||||
|
||||
@@ -1716,6 +1716,9 @@ route('POST', '/api/messages/upload', async (req, res) => {
|
||||
try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
|
||||
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); }
|
||||
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
|
||||
// Videos: build the capped/faststart streaming rendition in the background so it is ready before
|
||||
// anyone taps play. Never blocks the upload response, and playback falls back to the original.
|
||||
try { require('./media').ensureWebRendition(id, mime); } catch (e) {}
|
||||
finish(200, { id, name, mime, size: total });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ wss.on('connection', onConnection);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`HTTP on http://localhost:${PORT}`);
|
||||
try { require('./media').backfill(); } catch (e) {} // streaming renditions for older video uploads
|
||||
});
|
||||
|
||||
// HTTPS — required so other devices can share their screen (browsers block
|
||||
|
||||
+51
-22
@@ -6,6 +6,7 @@ const R = require('./repos');
|
||||
const { json } = require('./lib');
|
||||
const { currentUser } = require('./session');
|
||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('./config');
|
||||
const media = require('./media');
|
||||
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
||||
|
||||
@@ -37,6 +38,32 @@ function authAttachment(id, u) {
|
||||
return a;
|
||||
}
|
||||
|
||||
// Serve a file with HTTP Range support (206 Partial Content) — required for reliable iOS video
|
||||
// streaming and for seeking anywhere other than the start.
|
||||
function sendRanged(req, res, fp, headers) {
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
const total = st.size;
|
||||
const base = Object.assign({ 'Accept-Ranges': 'bytes' }, headers);
|
||||
const mm = req.headers.range && /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
|
||||
if (mm) {
|
||||
let start = mm[1] ? parseInt(mm[1], 10) : 0;
|
||||
let end = mm[2] ? parseInt(mm[2], 10) : total - 1;
|
||||
if (isNaN(start)) start = 0;
|
||||
if (isNaN(end) || end >= total) end = total - 1;
|
||||
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); return res.end(); }
|
||||
res.writeHead(206, Object.assign({}, base, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': (end - start + 1) }));
|
||||
const rs = fs.createReadStream(fp, { start, end });
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
return rs.pipe(res);
|
||||
}
|
||||
res.writeHead(200, Object.assign({}, base, { 'Content-Length': total }));
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
function serveStatic(req, res) {
|
||||
let p = req.url.split('?')[0];
|
||||
if (p === '/') p = '/index.html';
|
||||
@@ -201,6 +228,26 @@ function handleGet(req, res) {
|
||||
send();
|
||||
});
|
||||
}
|
||||
// PLAYBACK source for in-chat videos. Prefers the capped/faststart rendition built by media.js; falls
|
||||
// back to the original bytes while that is still transcoding, so a video is never unplayable. The
|
||||
// download button keeps pointing at /files, which always serves the untouched original.
|
||||
if (pathOnly.startsWith('/stream/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const id = path.basename(decodeURIComponent(pathOnly));
|
||||
const a = authAttachment(id, u);
|
||||
if (!a) return json(res, 404, { error: 'not found' });
|
||||
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
|
||||
const ready = media.hasWebRendition(id);
|
||||
const fp = ready ? media.webPath(id) : path.join(UPLOADS_DIR, id);
|
||||
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
return sendRanged(req, res, fp, {
|
||||
'Content-Type': ready ? 'video/mp4' : (a.mime || 'application/octet-stream'),
|
||||
'Content-Disposition': 'inline',
|
||||
// Don't let a browser cache the heavy original past the point the rendition lands.
|
||||
'Cache-Control': ready ? 'private, max-age=86400' : 'private, max-age=60',
|
||||
});
|
||||
}
|
||||
if (pathOnly.startsWith('/files/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
@@ -211,28 +258,10 @@ function handleGet(req, res) {
|
||||
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
const inline = /^(image|video|audio)\//.test(a.mime || ''); // media opens/plays inline; other files download
|
||||
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
const total = st.size;
|
||||
const base = { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Disposition': (inline ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400', 'Accept-Ranges': 'bytes' };
|
||||
// Range support — required for reliable iOS video streaming/seeking (206 Partial Content).
|
||||
const range = req.headers.range;
|
||||
const mm = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (mm) {
|
||||
let start = mm[1] ? parseInt(mm[1], 10) : 0;
|
||||
let end = mm[2] ? parseInt(mm[2], 10) : total - 1;
|
||||
if (isNaN(start)) start = 0;
|
||||
if (isNaN(end) || end >= total) end = total - 1;
|
||||
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); return res.end(); }
|
||||
res.writeHead(206, Object.assign({}, base, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': (end - start + 1) }));
|
||||
const rs = fs.createReadStream(fp, { start, end });
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
return rs.pipe(res);
|
||||
}
|
||||
res.writeHead(200, Object.assign({}, base, { 'Content-Length': total }));
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
return sendRanged(req, res, fp, {
|
||||
'Content-Type': a.mime || 'application/octet-stream',
|
||||
'Content-Disposition': (inline ? 'inline' : 'attachment') + '; filename="' + safeName + '"',
|
||||
'Cache-Control': 'private, max-age=86400',
|
||||
});
|
||||
}
|
||||
return serveStatic(req, res);
|
||||
|
||||
Reference in New Issue
Block a user