2026-05-06 20:07:15 -05:00
|
|
|
let _sharp: any = null;
|
2026-04-23 12:19:00 -05:00
|
|
|
let _sharpLoadAttempted = false;
|
|
|
|
|
|
2026-05-06 20:07:15 -05:00
|
|
|
async function getSharp(): Promise<any> {
|
2026-04-23 12:19:00 -05:00
|
|
|
if (_sharp) return _sharp;
|
|
|
|
|
if (_sharpLoadAttempted) throw new Error("sharp module unavailable");
|
|
|
|
|
_sharpLoadAttempted = true;
|
|
|
|
|
try {
|
|
|
|
|
_sharp = (await import("sharp")).default as any;
|
|
|
|
|
return _sharp!;
|
2026-04-27 21:52:35 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[thumbnails] Failed to load sharp module. Thumbnails will be unavailable.", err);
|
|
|
|
|
throw err;
|
2026-04-17 13:49:06 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-22 23:21:27 -05:00
|
|
|
|
2026-05-09 20:47:44 -05:00
|
|
|
const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920));
|
|
|
|
|
const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080));
|
|
|
|
|
const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88)));
|
2026-03-22 23:21:27 -05:00
|
|
|
const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
|
|
|
|
|
256 * 1024,
|
|
|
|
|
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
|
|
|
|
|
);
|
2026-05-17 22:02:23 -05:00
|
|
|
const THUMBNAIL_CACHE_MAX = Math.max(
|
|
|
|
|
64,
|
|
|
|
|
Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
|
|
|
|
|
);
|
2026-03-22 23:21:27 -05:00
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
type ThumbnailRecord = {
|
|
|
|
|
bytes: Uint8Array;
|
|
|
|
|
contentType: string;
|
|
|
|
|
version: number;
|
|
|
|
|
updatedAt: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const thumbnails = new Map<string, ThumbnailRecord>();
|
2026-03-22 23:21:27 -05:00
|
|
|
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
2026-03-05 00:04:26 -06:00
|
|
|
const thumbnailRequests = new Map<string, number>();
|
2026-05-21 09:51:12 -05:00
|
|
|
const thumbnailVersionHWM = new Map<string, number>();
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
function touchThumbnailLRU(id: string) {
|
|
|
|
|
const existing = thumbnails.get(id);
|
|
|
|
|
if (!existing) return;
|
|
|
|
|
thumbnails.delete(id);
|
|
|
|
|
thumbnails.set(id, existing);
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
function evictThumbnailsIfFull() {
|
|
|
|
|
while (thumbnails.size > THUMBNAIL_CACHE_MAX) {
|
|
|
|
|
const oldestKey = thumbnails.keys().next().value;
|
|
|
|
|
if (oldestKey === undefined) break;
|
|
|
|
|
thumbnails.delete(oldestKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasThumbnail(id: string): boolean {
|
|
|
|
|
return thumbnails.has(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getThumbnailRecord(id: string): ThumbnailRecord | null {
|
|
|
|
|
const rec = thumbnails.get(id);
|
|
|
|
|
if (!rec) return null;
|
|
|
|
|
touchThumbnailLRU(id);
|
|
|
|
|
return rec;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getThumbnailVersion(id: string): number {
|
|
|
|
|
return thumbnails.get(id)?.version ?? 0;
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearThumbnail(id: string) {
|
|
|
|
|
thumbnails.delete(id);
|
|
|
|
|
latestFrames.delete(id);
|
|
|
|
|
thumbnailRequests.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setLatestFrame(id: string, bytes: Uint8Array, format: string) {
|
2026-03-22 23:21:27 -05:00
|
|
|
if (!bytes || bytes.byteLength === 0 || bytes.byteLength > MAX_THUMBNAIL_SOURCE_BYTES) {
|
|
|
|
|
latestFrames.delete(id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
latestFrames.set(id, { bytes, format, capturedAt: Date.now() });
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
async function buildThumbnailBytes(bytes: Uint8Array, format: string): Promise<Uint8Array | null> {
|
2026-03-22 23:21:27 -05:00
|
|
|
if (!bytes || bytes.byteLength === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inputFormat = format === "jpg" ? "jpeg" : format;
|
|
|
|
|
if (!["jpeg", "webp"].includes(inputFormat)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 13:49:06 -05:00
|
|
|
const sharp = await getSharp();
|
2026-03-22 23:21:27 -05:00
|
|
|
const output = await sharp(Buffer.from(bytes), { failOn: "none" })
|
|
|
|
|
.rotate()
|
|
|
|
|
.resize({
|
|
|
|
|
width: THUMBNAIL_WIDTH,
|
|
|
|
|
height: THUMBNAIL_HEIGHT,
|
|
|
|
|
fit: "inside",
|
|
|
|
|
withoutEnlargement: true,
|
|
|
|
|
fastShrinkOnLoad: true,
|
|
|
|
|
})
|
|
|
|
|
.webp({ quality: THUMBNAIL_QUALITY })
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
return new Uint8Array(output);
|
2026-03-22 23:21:27 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function generateThumbnail(id: string): Promise<boolean> {
|
2026-03-05 00:04:26 -06:00
|
|
|
const frameData = latestFrames.get(id);
|
|
|
|
|
if (!frameData) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-03-22 23:21:27 -05:00
|
|
|
|
|
|
|
|
try {
|
2026-05-17 22:02:23 -05:00
|
|
|
const out = await buildThumbnailBytes(frameData.bytes, frameData.format);
|
|
|
|
|
if (!out) {
|
2026-03-22 23:21:27 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-05-17 22:02:23 -05:00
|
|
|
const prior = thumbnails.get(id);
|
|
|
|
|
const now = Date.now();
|
2026-05-21 09:51:12 -05:00
|
|
|
const hwm = thumbnailVersionHWM.get(id) ?? (prior?.version ?? 0);
|
|
|
|
|
const newVersion = hwm + 1;
|
|
|
|
|
thumbnailVersionHWM.set(id, newVersion);
|
2026-05-17 22:02:23 -05:00
|
|
|
if (prior) thumbnails.delete(id);
|
|
|
|
|
thumbnails.set(id, {
|
|
|
|
|
bytes: out,
|
|
|
|
|
contentType: "image/webp",
|
2026-05-21 09:51:12 -05:00
|
|
|
version: newVersion,
|
2026-05-17 22:02:23 -05:00
|
|
|
updatedAt: now,
|
|
|
|
|
});
|
|
|
|
|
evictThumbnailsIfFull();
|
2026-05-18 09:55:05 -05:00
|
|
|
if (latestFrames.get(id) === frameData) {
|
|
|
|
|
latestFrames.delete(id);
|
|
|
|
|
}
|
2026-03-22 23:21:27 -05:00
|
|
|
return true;
|
2026-04-23 12:19:00 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[thumbnails] Failed to generate thumbnail for client ${id}:`, err);
|
2026-03-22 23:21:27 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:55:05 -05:00
|
|
|
const thumbnailGenState = new Map<string, { inFlight: boolean; pending: boolean }>();
|
|
|
|
|
|
|
|
|
|
export async function requestThumbnailRegen(id: string): Promise<boolean> {
|
|
|
|
|
let state = thumbnailGenState.get(id);
|
|
|
|
|
if (!state) {
|
|
|
|
|
state = { inFlight: false, pending: false };
|
|
|
|
|
thumbnailGenState.set(id, state);
|
|
|
|
|
}
|
|
|
|
|
if (state.inFlight) {
|
|
|
|
|
state.pending = true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
state.inFlight = true;
|
|
|
|
|
let didGenerate = false;
|
|
|
|
|
try {
|
|
|
|
|
while (true) {
|
|
|
|
|
state.pending = false;
|
|
|
|
|
const ok = await generateThumbnail(id);
|
|
|
|
|
if (ok) didGenerate = true;
|
|
|
|
|
if (!state.pending) break;
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
state.inFlight = false;
|
|
|
|
|
if (thumbnailGenState.get(id) === state && !state.pending) {
|
|
|
|
|
thumbnailGenState.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return didGenerate;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function markThumbnailRequested(id: string) {
|
|
|
|
|
thumbnailRequests.set(id, Date.now());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:55:05 -05:00
|
|
|
export function isThumbnailRequested(id: string, windowMs = 5000): boolean {
|
|
|
|
|
const ts = thumbnailRequests.get(id);
|
|
|
|
|
if (!ts) return false;
|
|
|
|
|
if (Date.now() - ts > windowMs) {
|
|
|
|
|
thumbnailRequests.delete(id);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearThumbnailRequest(id: string) {
|
|
|
|
|
thumbnailRequests.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
|
|
|
|
|
const ts = thumbnailRequests.get(id);
|
|
|
|
|
if (!ts) return false;
|
|
|
|
|
if (Date.now() - ts > windowMs) {
|
|
|
|
|
thumbnailRequests.delete(id);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
thumbnailRequests.delete(id);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-18 09:55:05 -05:00
|
|
|
|
|
|
|
|
const thumbnailWaiters = new Map<string, Set<() => void>>();
|
|
|
|
|
|
|
|
|
|
export function notifyThumbnailGenerated(id: string) {
|
|
|
|
|
const waiters = thumbnailWaiters.get(id);
|
|
|
|
|
if (!waiters) return;
|
|
|
|
|
thumbnailWaiters.delete(id);
|
|
|
|
|
for (const cb of waiters) {
|
|
|
|
|
try { cb(); } catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function waitForThumbnail(id: string, timeoutMs = 2500): Promise<boolean> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
let settled = false;
|
|
|
|
|
let callback: () => void;
|
|
|
|
|
const finish = (fresh: boolean) => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
const set = thumbnailWaiters.get(id);
|
|
|
|
|
if (set) {
|
|
|
|
|
set.delete(callback);
|
|
|
|
|
if (set.size === 0) thumbnailWaiters.delete(id);
|
|
|
|
|
}
|
|
|
|
|
resolve(fresh);
|
|
|
|
|
};
|
|
|
|
|
callback = () => finish(true);
|
|
|
|
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
|
|
|
let set = thumbnailWaiters.get(id);
|
|
|
|
|
if (!set) {
|
|
|
|
|
set = new Set();
|
|
|
|
|
thumbnailWaiters.set(id, set);
|
|
|
|
|
}
|
|
|
|
|
set.add(callback);
|
|
|
|
|
});
|
|
|
|
|
}
|