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:
+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