lab-js/realtime/realtime-gallery.jsjavascript
/**
* @module realtime-gallery
* @description
* Stores, renders and exports spike captures.
*
* STORAGE. IndexedDB, not localStorage. localStorage caps around 5MB per
* origin, is shared with the baseline, the settings and the existing thumbnail
* store, and inflates binary by a third on the way through base64 — a 640x480
* JPEG lands near 70KB encoded, so the quota starts evicting things you care
* about after roughly fifty frames. IndexedDB takes Blobs natively.
*
* PRIVACY. This module is the point where the tool starts holding photographs
* of a face rather than a 128-d template. Everything stays on the device, the
* count is always visible in the settings bar, and "Delete all" really empties
* the store. Deleting the baseline deliberately does NOT clear captures: the
* gallery is expected to outlive a re-baseline, and that is stated in the UI
* rather than left to be inferred.
*
* EXPORT. Store-only ZIP written here in ~70 lines. JPEGs do not compress, so
* DEFLATE would buy nothing, and pulling in JSZip would put a third-party
* script on a page whose whole point is that it makes no third-party requests.
*/
const DB_NAME = 'gstmxx-realtime';
const DB_VERSION = 1;
const STORE = 'captures';
/**
* Opens (and upgrades) the capture database.
*
* @returns {Promise<IDBDatabase>} Open database handle.
*/
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
store.createIndex('t', 't');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Runs one transaction against the capture store.
*
* @param {IDBDatabase} db Open database.
* @param {IDBTransactionMode} mode Transaction mode.
* @param {function(IDBObjectStore): IDBRequest} work Callback issuing the request.
* @returns {Promise<*>} Request result.
*/
function run(db, mode, work) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const request = work(tx.objectStore(STORE));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Formats a timestamp for filenames.
*
* @param {number} timestamp Epoch milliseconds.
* @param {boolean} [fileSafe] Whether to avoid characters illegal in filenames.
* @returns {string} Formatted stamp.
*/
function stamp(timestamp, fileSafe = false) {
const d = new Date(timestamp);
const pad = (n) => String(n).padStart(2, '0');
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const time = `${pad(d.getHours())}${fileSafe ? '' : ':'}${pad(d.getMinutes())}${fileSafe ? '' : ':'}${pad(d.getSeconds())}`;
return fileSafe ? `${date}_${time}` : `${date} ${time}`;
}
/**
* Formats the only measurement claim shown with a saved frame.
*
* @param {object} meta Measurement metadata.
* @param {number} meta.distance Distance at capture.
* @param {number} meta.threshold Match threshold used for the verdict.
* @returns {string} Human-readable distance and verdict.
*/
export function formatCaptureMeasurement(meta) {
const threshold = Number.isFinite(meta.threshold) ? meta.threshold : 0.6;
const distance = Number.isFinite(meta.distance) ? meta.distance : NaN;
const formattedDistance = Number.isFinite(distance) ? distance.toFixed(3) : '—';
const verdict = Number.isFinite(distance) && distance <= threshold
? 'still match'
: 'different person';
return `distance ${formattedDistance}/${threshold.toFixed(2)} · ${verdict}`;
}
/** Draws the project mark using the same geometry as mark-small-mono-cream.svg. */
function drawLogoMark(ctx, x, y, size) {
ctx.save();
ctx.translate(x, y);
ctx.scale(size / 64, size / 64);
ctx.strokeStyle = '#fff1cf';
ctx.fillStyle = '#fff1cf';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.save();
ctx.translate(1, 10);
ctx.scale(0.45, 0.45);
ctx.lineWidth = 7.5;
ctx.beginPath();
ctx.moveTo(8, 44);
ctx.lineTo(2, 32);
ctx.moveTo(92, 44);
ctx.lineTo(98, 32);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(50, 45);
ctx.bezierCurveTo(65, 25, 90, 25, 90, 45);
ctx.bezierCurveTo(90, 65, 65, 65, 50, 45);
ctx.bezierCurveTo(35, 25, 10, 25, 10, 45);
ctx.bezierCurveTo(10, 65, 35, 65, 50, 45);
ctx.closePath();
ctx.stroke();
for (const cx of [28, 72]) {
ctx.beginPath();
ctx.arc(cx, 45, 8, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.save();
ctx.translate(13, 36);
ctx.scale(0.4, 0.4);
ctx.lineWidth = 9;
ctx.beginPath();
ctx.moveTo(80, 26);
ctx.lineTo(80, 84);
ctx.moveTo(80, 30);
ctx.lineTo(44, 30);
ctx.moveTo(52, 30);
ctx.lineTo(52, 40);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(17, 40);
ctx.lineTo(75, 40);
ctx.arcTo(82, 40, 82, 47, 7);
ctx.lineTo(82, 63);
ctx.arcTo(82, 70, 75, 70, 7);
ctx.lineTo(17, 70);
ctx.arcTo(10, 70, 10, 63, 7);
ctx.lineTo(10, 47);
ctx.arcTo(10, 40, 17, 40, 7);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.arc(32, 55, 11.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.restore();
}
/**
* Burns the project mark and the distance verdict into the frame.
*
* @param {HTMLCanvasElement} source Frame to watermark.
* @param {object} meta Measurement metadata.
* @param {number} meta.distance Distance at capture.
* @param {number} meta.threshold Cited match threshold.
* @returns {HTMLCanvasElement} Watermarked canvas.
*/
export function watermark(source, meta) {
const barHeight = Math.max(26, Math.round(source.height * 0.062));
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height + barHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(source, 0, 0);
ctx.fillStyle = '#0c0906';
ctx.fillRect(0, source.height, canvas.width, barHeight);
const markSize = Math.max(24, Math.round(barHeight * 0.82));
const markX = 12;
const markY = source.height + (barHeight - markSize) / 2;
drawLogoMark(ctx, markX, markY, markSize);
const detail = formatCaptureMeasurement(meta);
const textX = markX + markSize + 12;
const availableWidth = Math.max(0, canvas.width - textX - 12);
let fontSize = Math.max(9, Math.round(barHeight * 0.36));
ctx.font = `${fontSize}px ui-monospace, SFMono-Regular, Menlo, monospace`;
while (fontSize > 8 && ctx.measureText(detail).width > availableWidth) {
fontSize -= 1;
ctx.font = `${fontSize}px ui-monospace, SFMono-Regular, Menlo, monospace`;
}
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const y = source.height + barHeight / 2;
ctx.fillStyle = '#ffe7a8';
ctx.fillText(detail, textX, y, availableWidth);
return canvas;
}
/**
* Encodes a canvas to a Blob.
*
* @param {HTMLCanvasElement} canvas Canvas to encode.
* @param {string} type MIME type.
* @param {number} [quality] Encoder quality for lossy types.
* @returns {Promise<Blob>} Encoded blob.
*/
function toBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Encoding failed'))),
type,
quality,
);
});
}
/* --- Store-only ZIP -------------------------------------------------------- */
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
/**
* Computes a CRC-32 checksum.
*
* @param {Uint8Array} bytes Input bytes.
* @returns {number} Unsigned checksum.
*/
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i += 1) {
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
/**
* Encodes a date as a DOS time/date pair.
*
* @param {Date} date Source date.
* @returns {{time: number, date: number}} DOS fields.
*/
function dosDate(date) {
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
date: ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
/**
* Builds an uncompressed ZIP archive.
*
* @param {Array<{name: string, bytes: Uint8Array, date: Date}>} files Archive members.
* @returns {Blob} ZIP blob.
*/
export function buildZip(files) {
const encoder = new TextEncoder();
const locals = [];
const centrals = [];
let offset = 0;
for (const file of files) {
const nameBytes = encoder.encode(file.name);
const checksum = crc32(file.bytes);
const { time, date } = dosDate(file.date);
const local = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0, true);
lv.setUint16(8, 0, true);
lv.setUint16(10, time, true);
lv.setUint16(12, date, true);
lv.setUint32(14, checksum, true);
lv.setUint32(18, file.bytes.length, true);
lv.setUint32(22, file.bytes.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
local.set(nameBytes, 30);
const central = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 20, true);
cv.setUint16(6, 20, true);
cv.setUint16(8, 0, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, time, true);
cv.setUint16(14, date, true);
cv.setUint32(16, checksum, true);
cv.setUint32(20, file.bytes.length, true);
cv.setUint32(24, file.bytes.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true);
central.set(nameBytes, 46);
locals.push(local, file.bytes);
centrals.push(central);
offset += local.length + file.bytes.length;
}
const centralSize = centrals.reduce((sum, part) => sum + part.length, 0);
const end = new Uint8Array(22);
const ev = new DataView(end.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, files.length, true);
ev.setUint16(10, files.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);
return new Blob([...locals, ...centrals, end], { type: 'application/zip' });
}
/**
* Triggers a browser download for a blob.
*
* @param {Blob} blob Payload.
* @param {string} filename Suggested filename.
* @returns {void}
*/
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
}
/**
* Creates the gallery controller.
*
* @param {object} options Wiring options.
* @param {HTMLElement} options.viewerEl Viewer overlay root.
* @param {HTMLImageElement} options.imageEl Viewer image element.
* @param {HTMLElement} options.metaEl Viewer metadata line.
* @param {HTMLElement} options.countEl Settings-bar count element.
* @param {function(number): void} options.onCountChange Count change callback.
* @param {function(string): void} options.onDelete Deletion callback.
* @param {function(string): void} options.onStatus Status message callback.
* @returns {object} Gallery controller.
*/
export function createGallery(options) {
const { viewerEl, imageEl, metaEl, countEl, onCountChange, onDelete, onStatus } = options;
let db = null;
let count = 0;
let openId = null;
let openUrl = null;
/**
* Publishes the current capture count.
*
* @returns {void}
*/
function publishCount() {
if (countEl) countEl.textContent = String(count);
if (typeof onCountChange === 'function') onCountChange(count);
}
/**
* Opens the database and reads the current count.
*
* @returns {Promise<void>}
*/
async function init() {
db = await openDb();
count = await run(db, 'readonly', (store) => store.count());
publishCount();
}
/**
* Watermarks, encodes and stores one captured frame.
*
* @param {HTMLCanvasElement} frame Raw captured frame.
* @param {object} meta Measurement metadata.
* @returns {Promise<string>} Stored capture id.
*/
async function save(frame, meta) {
if (!db) throw new Error('Gallery not ready');
const marked = watermark(frame, meta);
const blob = await toBlob(marked, 'image/jpeg', 0.85);
const id = `cap_${meta.t}_${Math.random().toString(36).slice(2, 7)}`;
await run(db, 'readwrite', (store) =>
store.put({
id,
t: meta.t,
distance: meta.distance,
noiseFloor: meta.noiseFloor,
threshold: meta.threshold,
kind: meta.kind || 'manual',
blob,
}),
);
count += 1;
publishCount();
return id;
}
/**
* Displays one stored capture over the video stage.
*
* @param {string} id Capture id.
* @returns {Promise<void>}
*/
async function open(id) {
if (!db) return;
const record = await run(db, 'readonly', (store) => store.get(id));
if (!record) return;
if (openUrl) URL.revokeObjectURL(openUrl);
openUrl = URL.createObjectURL(record.blob);
openId = id;
imageEl.src = openUrl;
metaEl.textContent = formatCaptureMeasurement(record);
viewerEl.hidden = false;
}
/**
* Closes the viewer overlay and releases its object URL.
*
* @returns {void}
*/
function close() {
viewerEl.hidden = true;
imageEl.removeAttribute('src');
if (openUrl) {
URL.revokeObjectURL(openUrl);
openUrl = null;
}
openId = null;
}
/**
* Copies the open capture to the clipboard.
*
* The Clipboard API only reliably accepts PNG, so the JPEG is re-encoded on
* the way out. Falls back to a download where the API is unavailable.
*
* @returns {Promise<void>}
*/
async function copyOpen() {
if (!db || !openId) return;
const record = await run(db, 'readonly', (store) => store.get(openId));
if (!record) return;
try {
const bitmap = await createImageBitmap(record.blob);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
const png = await toBlob(canvas, 'image/png');
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
onStatus('Capture copied to the clipboard.');
} catch {
download(record.blob, `ghostmaxxing_${stamp(record.t, true)}.jpg`);
onStatus('Clipboard unavailable — downloaded instead.');
}
}
/**
* Downloads the open capture.
*
* @returns {Promise<void>}
*/
async function downloadOpen() {
if (!db || !openId) return;
const record = await run(db, 'readonly', (store) => store.get(openId));
if (!record) return;
download(record.blob, `ghostmaxxing_${stamp(record.t, true)}.jpg`);
}
/**
* Deletes the open capture.
*
* @returns {Promise<void>}
*/
async function deleteOpen() {
if (!db || !openId) return;
const id = openId;
await run(db, 'readwrite', (store) => store.delete(id));
count = Math.max(0, count - 1);
publishCount();
close();
if (typeof onDelete === 'function') onDelete(id);
onStatus('Capture deleted.');
}
/**
* Exports every stored capture as one uncompressed archive.
*
* @returns {Promise<void>}
*/
async function downloadAll() {
if (!db || !count) return;
onStatus('Building archive…');
const records = await run(db, 'readonly', (store) => store.getAll());
records.sort((a, b) => a.t - b.t);
const files = [];
for (const record of records) {
const bytes = new Uint8Array(await record.blob.arrayBuffer());
files.push({
name: `ghostmaxxing_${stamp(record.t, true)}_d${record.distance.toFixed(3)}.jpg`,
bytes,
date: new Date(record.t),
});
}
download(buildZip(files), `ghostmaxxing_captures_${stamp(Date.now(), true)}.zip`);
onStatus(`Exported ${files.length} capture${files.length === 1 ? '' : 's'}.`);
}
/**
* Empties the capture store.
*
* @returns {Promise<void>}
*/
async function deleteAll() {
if (!db) return;
await run(db, 'readwrite', (store) => store.clear());
count = 0;
publishCount();
close();
if (typeof onDelete === 'function') onDelete(null);
onStatus('All captures deleted from this device.');
}
return {
init,
save,
open,
close,
copyOpen,
downloadOpen,
deleteOpen,
downloadAll,
deleteAll,
/**
* Reports how many captures are stored.
*
* @returns {number} Capture count.
*/
size() {
return count;
},
/**
* Reports whether the viewer overlay is showing.
*
* @returns {boolean} True while open.
*/
isOpen() {
return !viewerEl.hidden;
},
};
}