Performance updates
Deploy / deploy (push) Successful in 2m48s

This commit is contained in:
twotalesanimation
2026-07-14 08:27:58 +02:00
parent d4a0250c20
commit b390c0bb35
2 changed files with 130 additions and 24 deletions
+119 -23
View File
@@ -2,6 +2,32 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
// ── Tuning constants ──────────────────────────────────────────────────────────
//
// CHUNK_SIZE — maximum bytes returned per Range response.
//
// Target workload: 220 s H.264 MP4 clips at 3050 Mbps = ~7125 MB per file,
// 13 concurrent reviewers on a LAN/office network.
//
// At 8 MB each chunk:
// • A 7 MB clip (2 s @ 30 Mbps) arrives in a single request.
// • A 75 MB clip (20 s @ 30 Mbps) needs ~10 requests total.
// • A 125 MB clip (20 s @ 50 Mbps) needs ~16 requests total.
//
// The previous 2 MB cap produced 4× as many HTTP round-trips and 4× as many
// file-open/stat operations for the same content, with no benefit for this
// workload. Larger values (16 MB+) offer diminishing returns and increase
// per-request heap pressure.
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
// READ_HIGH_WATER_MARK — libuv/Node.js internal read-buffer size per stream.
//
// Default is 64 KB, meaning an 8 MB chunk requires ~128 kernel→userspace copy
// iterations. 512 KB reduces that to ~16 iterations, lowering V8 GC pressure
// and improving throughput for large sequential reads. Still small enough that
// 3 concurrent streams only hold ~1.5 MB of read buffers in total.
const READ_HIGH_WATER_MARK = 512 * 1024; // 512 KB
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ key: string[] }> } { params }: { params: Promise<{ key: string[] }> }
@@ -10,19 +36,31 @@ export async function GET(
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"] // key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
const relativePath = key.join("/"); const relativePath = key.join("/");
// Sanitize: prevent path traversal // ── Path traversal sanitisation ───────────────────────────────────────────
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads"); const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
const filePath = path.resolve(path.join(uploadDir, relativePath)); const filePath = path.resolve(path.join(uploadDir, relativePath));
if (!filePath.startsWith(uploadDir)) { // Append path.sep so "/uploads2/evil.mp4".startsWith("/uploads") cannot
// pass — the resolved path must be strictly inside the upload directory.
if (!filePath.startsWith(uploadDir + path.sep)) {
return new NextResponse("Forbidden", { status: 403 }); return new NextResponse("Forbidden", { status: 403 });
} }
if (!fs.existsSync(filePath)) { // Single stat() replaces the previous existsSync() + statSync() (2 → 1
// syscall). The try/catch also catches ENOENT, EACCES, ENAMETOOLONG, etc.
let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
const stat = fs.statSync(filePath); // Guard against directory traversal that resolves to a directory
if (!stat.isFile()) {
return new NextResponse("Not found", { status: 404 });
}
// ── Content-Type ──────────────────────────────────────────────────────────
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = { const mimeMap: Record<string, string> = {
".mp4": "video/mp4", ".mp4": "video/mp4",
@@ -33,35 +71,93 @@ export async function GET(
}; };
const contentType = mimeMap[ext] ?? "application/octet-stream"; const contentType = mimeMap[ext] ?? "application/octet-stream";
// Support range requests so the HTML5 video player can seek // ── Caching headers ───────────────────────────────────────────────────────
// ETag: stable because uploaded files are immutable — re-uploads produce
// a new UUID filename, so mtime and size never change for a given path.
const etag = `"${stat.size.toString(36)}-${stat.mtimeMs.toString(36)}"`;
const lastModified = stat.mtime.toUTCString();
const sharedHeaders = {
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600, immutable",
"ETag": etag,
"Last-Modified": lastModified,
};
const rangeHeader = req.headers.get("range"); const rangeHeader = req.headers.get("range");
// ── Range request path (RFC 7233) ─────────────────────────────────────────
if (rangeHeader) { if (rangeHeader) {
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-"); // RFC 7233 §3.2 — If-Range: only honour Range if the representation is
const start = parseInt(startStr, 10); // unchanged. If the validator doesn't match, fall through to a full 200.
const end = endStr ? parseInt(endStr, 10) : stat.size - 1; const ifRange = req.headers.get("if-range");
const chunkSize = end - start + 1; const rangeIsValid = !ifRange || ifRange === etag || ifRange === lastModified;
const stream = fs.createReadStream(filePath, { start, end }); if (rangeIsValid) {
const nodeStream = stream as unknown as ReadableStream; // Parse "bytes=<start>-<end>" with regex to handle surrounding whitespace
const rangeMatch = /bytes\s*=\s*(\d*)-(\d*)/i.exec(rangeHeader);
if (!rangeMatch) {
// Malformed Range header → 416 Range Not Satisfiable
return new NextResponse(null, {
status: 416,
headers: { ...sharedHeaders, "Content-Range": `bytes */${stat.size}` },
});
}
return new NextResponse(nodeStream, { const start = rangeMatch[1] ? parseInt(rangeMatch[1], 10) : 0;
status: 206,
headers: { // RFC 7233 §2.1: start byte past EOF → 416
"Content-Range": `bytes ${start}-${end}/${stat.size}`, if (start >= stat.size) {
"Accept-Ranges": "bytes", return new NextResponse(null, {
"Content-Length": String(chunkSize), status: 416,
"Content-Type": contentType, headers: { ...sharedHeaders, "Content-Range": `bytes */${stat.size}` },
}, });
}); }
// Cap end: honour explicit client requests but never exceed CHUNK_SIZE
// beyond start, and never past EOF.
const requestedEnd = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : stat.size - 1;
const end = Math.min(requestedEnd, start + CHUNK_SIZE - 1, stat.size - 1);
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, {
start,
end,
highWaterMark: READ_HIGH_WATER_MARK,
}) as unknown as ReadableStream;
return new NextResponse(stream, {
status: 206,
headers: {
...sharedHeaders,
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Content-Length": String(chunkSize),
},
});
}
// If-Range mismatch: fall through to full-file response below
} }
const stream = fs.createReadStream(filePath) as unknown as ReadableStream; // ── Full-file response (no Range header, or If-Range mismatch) ────────────
// RFC 7232: 304 Not Modified applies only to full-file (non-range) requests
const ifNoneMatch = req.headers.get("if-none-match");
const ifModifiedSince = req.headers.get("if-modified-since");
if (
(ifNoneMatch && ifNoneMatch === etag) ||
(!ifNoneMatch && ifModifiedSince && new Date(ifModifiedSince) >= stat.mtime)
) {
return new NextResponse(null, { status: 304 });
}
const stream = fs.createReadStream(filePath, {
highWaterMark: READ_HIGH_WATER_MARK,
}) as unknown as ReadableStream;
return new NextResponse(stream, { return new NextResponse(stream, {
headers: { headers: {
...sharedHeaders,
"Content-Length": String(stat.size), "Content-Length": String(stat.size),
"Content-Type": contentType,
"Accept-Ranges": "bytes",
}, },
}); });
} }
+11 -1
View File
@@ -81,6 +81,16 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setFps(fps); setFps(fps);
}, [fps, setFps]); }, [fps, setFps]);
// Reset playback state when the source changes so the timeline and
// timecode don't briefly show the previous clip's values while the new
// video is loading its metadata.
useEffect(() => {
setCurrentFrame(0);
setCurrentTime(0);
setDuration(0);
setTotalFrames(0);
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames]);
// ── Playback state sync ────────────────────────────────────────────────── // ── Playback state sync ──────────────────────────────────────────────────
const handleTimeUpdate = useCallback(() => { const handleTimeUpdate = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -269,7 +279,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onPlay={handlePlay} onPlay={handlePlay}
onPause={handlePause} onPause={handlePause}
preload="metadata" preload="auto"
playsInline playsInline
/> />