Backgrounds metadata population and frontend colors (#5092) * Startup and on-demand check * Update comments, add color preload * remove redundant metadata generation * changed image-metadata/all from GET to POST * move initializeAllUserMetadata to image-metadata.js, make blocking (?) * Add type annotations --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

4550afd4cae623ce3e63b0bc77b95f0c205b806c

L <123923688+Vibecoder9000@users.noreply.github.com>

Signed
4 files changed, +157 -7Ignore whitespace
public/scripts/backgrounds.js+47 -0
@@ -42,6 +42,12 @@ const THUMBNAIL_CONFIG = {
4242};
4343
4444/**
45+ * Cache for image metadata.
46+ * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
47+ */
48+const METADATA_CACHE = new Map();
49+
50+/**
4551 * Background source types.
4652 * @readonly
4753 * @enum {number}
@@ -89,6 +95,18 @@ function createThumbnailElement(imageData) {
8995 clipper.className = 'thumbnail-clipper lazy-load-background';
9096 clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
9197
98+ // Apply dominant color and aspect ratio as placeholder if available
99+ const metadataKey = isCustom ? bg : `backgrounds/${bg}`;
100+ const metadata = METADATA_CACHE.get(metadataKey);
101+ if (metadata) {
102+ if (metadata.dominantColor) {
103+ clipper.style.backgroundColor = metadata.dominantColor;
104+ }
105+ if (metadata.aspectRatio) {
106+ thumbnail.css('aspect-ratio', metadata.aspectRatio);
107+ }
108+ }
109+
92110 const titleElement = thumbnail.find('.BGSampleTitle');
93111 clipper.appendChild(titleElement.get(0));
94112 thumbnail.append(clipper);
@@ -548,6 +566,8 @@ function renderChatBackgrounds(backgrounds) {
548566}
549567
550568export async function getBackgrounds() {
569+ const metadataPromise = preloadImageMetadata();
570+
551571 const response = await fetch('/api/backgrounds/all', {
552572 method: 'POST',
553573 headers: getRequestHeaders(),
@@ -557,11 +577,38 @@ export async function getBackgrounds() {
557577 const { images, config } = await response.json();
558578 Object.assign(THUMBNAIL_CONFIG, config);
559579
580+ await metadataPromise;
581+
560582 renderSystemBackgrounds(images);
561583 highlightSelectedBackground();
562584 }
563585}
564586
587+/**
588+ * Preloads all image metadata to use dominant colors as placeholders.
589+ * @return {Promise<void>}
590+ */
591+async function preloadImageMetadata() {
592+ try {
593+ const response = await fetch('/api/image-metadata/all', {
594+ method: 'POST',
595+ headers: getRequestHeaders(),
596+ body: JSON.stringify({ prefix: 'backgrounds/' }),
597+ });
598+ if (response.ok) {
599+ const data = await response.json();
600+ if (data?.images) {
601+ METADATA_CACHE.clear();
602+ for (const [path, metadata] of Object.entries(data.images)) {
603+ METADATA_CACHE.set(path, metadata);
604+ }
605+ }
606+ }
607+ } catch (error) {
608+ console.error('[ImageMetadata] Failed to preload metadata:', error);
609+ }
610+}
611+
565612function activateLazyLoader() {
566613 // Disconnect previous observer to prevent memory leaks
567614 if (lazyLoadObserver) {
src/endpoints/backgrounds.js+24 -2
@@ -5,13 +5,13 @@ import express from 'express';
55import sanitize from 'sanitize-filename';
66
77import { invalidateThumbnail } from './thumbnails.js';
88import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js';
99import { getImages } from '../util.js';
1010import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111
1212export const router = express.Router();
1313
1414router.post('/all', async function (request, response) {
1515 const images = getImages(request.user.directories.backgrounds);
1616 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };
1717 response.json({ images, config });
@@ -34,6 +34,13 @@ router.post('/delete', getFileNameValidationFunction('bg'), function (request, r
3434
3535 fs.unlinkSync(fileName);
3636 invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
37+
38+ // Remove metadata for deleted image
39+ const relativePath = path.join('backgrounds', request.body.bg);
40+ removeMetadata(request.user.directories.root, relativePath).catch(err => {
41+ console.warn('[Backgrounds] Failed to remove metadata:', err.message);
42+ });
43+
3744 return response.send('ok');
3845});
3946
@@ -56,6 +63,14 @@ router.post('/rename', function (request, response) {
5663 fs.copyFileSync(oldFileName, newFileName);
5764 fs.unlinkSync(oldFileName);
5865 invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
66+
67+ // Update metadata for renamed image
68+ const oldRelativePath = path.join('backgrounds', request.body.old_bg);
69+ const newRelativePath = path.join('backgrounds', request.body.new_bg);
70+ renameMetadata(request.user.directories.root, oldRelativePath, newRelativePath).catch(err => {
71+ console.warn('[Backgrounds] Failed to rename metadata:', err.message);
72+ });
73+
5974 return response.send('ok');
6075});
6176
@@ -69,6 +84,13 @@ router.post('/upload', function (request, response) {
6984 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
7085 fs.unlinkSync(img_path);
7186 invalidateThumbnail(request.user.directories, 'bg', filename);
87+
88+ // Generate metadata for the new image
89+ const relativePath = path.join('backgrounds', filename);
90+ getOrGenerateMetadataBatch(request.user.directories.root, [relativePath], 'bg').catch(err => {
91+ console.warn('[Backgrounds] Failed to generate metadata for upload:', err.message);
92+ });
93+
7294 response.send(filename);
7395 } catch (err) {
7496 console.error(err);
src/endpoints/image-metadata.js+82 -5
@@ -10,7 +10,7 @@ import { imageSize } from 'image-size';
1010import writeFileAtomic from 'write-file-atomic';
1111import express from 'express';
1212import { Jimp } from '../jimp.js';
1313import { getConfigValue, getImages, isPathUnderParent } from '../util.js';
1414
1515export const METADATA_FILE = 'image-metadata.json';
1616
@@ -188,13 +188,14 @@ export async function writeMetadataIndex(userDataRoot, metadata) {
188188 * @param {string} userDataRoot - Path to the user data directory root
189189 * @param {string[]} relativePaths - Array of relative paths from userDataRoot
190190 * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
191191 * @returns {Promise<{results: Object.<string, ImageMetadata>, generatedCount: number}>} MapResults ofmap relativePathand tocount metadataof newly generated
192192 */
193193export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) {
194194 /** @type {Object.<string, ImageMetadata>} */
195195 const results = {};
196196 const index = await readMetadataIndex(userDataRoot);
197197 let indexModified = false;
198+ let generatedCount = 0;
198199
199200 for (const relativePath of relativePaths) {
200201 // Normalize the path to use forward slashes for consistent keys
@@ -230,6 +231,7 @@ export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, ty
230231 index.images[posixPath] = metadata;
231232 results[relativePath] = metadata;
232233 indexModified = true;
234+ generatedCount++;
233235 } catch (error) {
234236 console.warn(`[ImageMetadata] Failed to generate metadata for ${relativePath}:`, error.message);
235237 }
@@ -240,7 +242,7 @@ export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, ty
240242 await writeMetadataIndex(userDataRoot, index);
241243 }
242244
243245 return { results, generatedCount };
244246}
245247
246248/**
@@ -317,6 +319,52 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
317319 return orphanedPaths;
318320}
319321
322+/**
323+ * Initializes metadata for all images in a directory.
324+ * @param {string} userDataRoot - Path to the user data directory root
325+ * @param {string} subDirectory - Subdirectory relative to userDataRoot
326+ * @param {ThumbnailType} type - The thumbnail type for resolution calculation
327+ * @returns {Promise<number>} Number of images processed
328+ */
329+export async function initializeMetadataForDirectory(userDataRoot, subDirectory, type) {
330+ const fullDir = path.join(userDataRoot, subDirectory);
331+
332+ let images;
333+ try {
334+ images = getImages(fullDir);
335+ } catch {
336+ // Directory doesn't exist or can't be read
337+ return 0;
338+ }
339+
340+ // Convert to relative paths from userDataRoot
341+ const relativePaths = images.map(img => path.join(subDirectory, img));
342+
343+ // Generate metadata for new images
344+ const { results, generatedCount } = await getOrGenerateMetadataBatch(userDataRoot, relativePaths, type);
345+
346+ if (generatedCount > 0) {
347+ console.log(`[ImageMetadata] Generated metadata for ${generatedCount} new images in ${subDirectory}`);
348+ }
349+
350+ return Object.keys(results).length;
351+}
352+
353+/**
354+ * Initializes image metadata for all users' background directories.
355+ * @param {Array<{root: string}>} userDirectories - List of user directory objects
356+ * @returns {Promise<void>}
357+ */
358+export async function initializeAllUserMetadata(userDirectories) {
359+ try {
360+ for (const userDir of userDirectories) {
361+ await initializeMetadataForDirectory(userDir.root, 'backgrounds', 'bg');
362+ }
363+ } catch (error) {
364+ console.error('[ImageMetadata] Failed to initialize background metadata:', error.message);
365+ }
366+}
367+
320368export const router = express.Router();
321369
322370/**
@@ -353,7 +401,7 @@ router.post('/', async function (request, response) {
353401 return response.status(404).json({ error: 'File not found.' });
354402 }
355403
356404 const { results: metadataResults } = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type);
357405 const metadata = metadataResults[relativePath];
358406
359407 if (!metadata) {
@@ -380,7 +428,7 @@ router.post('/', async function (request, response) {
380428 }
381429
382430 // Process all valid paths in a single batch
383431 const { results: batchMetadata } = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type);
384432
385433 for (const relativePath of validPaths) {
386434 if (batchMetadata[relativePath]) {
@@ -402,6 +450,35 @@ router.post('/', async function (request, response) {
402450});
403451
404452/**
453+ * POST /api/image-metadata/all
454+ * Get all metadata from the index.
455+ * @body {string} [prefix] - Optional path prefix to filter results
456+ */
457+router.post('/all', async function (request, response) {
458+ try {
459+ const userDataRoot = request.user.directories.root;
460+ const prefix = String(request.body.prefix || '');
461+ const index = await readMetadataIndex(userDataRoot);
462+
463+ // If prefix specified, filter to only matching paths
464+ if (prefix) {
465+ const filteredImages = {};
466+ for (const [key, value] of Object.entries(index.images)) {
467+ if (key.startsWith(prefix)) {
468+ filteredImages[key] = value;
469+ }
470+ }
471+ return response.json({ version: index.version, images: filteredImages });
472+ }
473+
474+ return response.json(index);
475+ } catch (error) {
476+ console.error('[ImageMetadata] Failed to read metadata index:', error);
477+ return response.status(500).json({ error: 'Internal server error.' });
478+ }
479+});
480+
481+/**
405482 * POST /api/image-metadata/cleanup
406483 * Clean up orphaned metadata entries (files that no longer exist).
407484 */
src/server-main.js+4 -0
@@ -69,6 +69,7 @@ import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } fro
6969import { diskCache } from './endpoints/characters.js';
7070import { migrateFlatSecrets } from './endpoints/secrets.js';
7171import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js';
72+import { initializeAllUserMetadata } from './endpoints/image-metadata.js';
7273
7374// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
7475// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -276,6 +277,9 @@ async function preSetupTasks() {
276277 await settingsInit();
277278 await statsInit();
278279
280+ // Initialize image metadata
281+ await initializeAllUserMetadata(directories);
282+
279283 const pluginsDirectory = path.join(serverDirectory, 'plugins');
280284 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
281285 const consoleTitle = process.title;