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 = {
42};42};
4343
44/**44/**
45 * Cache for image metadata.
46 * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
47 */
48const METADATA_CACHE = new Map();
49
50/**
45 * Background source types.51 * Background source types.
46 * @readonly52 * @readonly
47 * @enum {number}53 * @enum {number}
@@ -89,6 +95,18 @@ function createThumbnailElement(imageData) {
89 clipper.className = 'thumbnail-clipper lazy-load-background';95 clipper.className = 'thumbnail-clipper lazy-load-background';
90 clipper.style.backgroundImage = PLACEHOLDER_IMAGE;96 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
92 const titleElement = thumbnail.find('.BGSampleTitle');110 const titleElement = thumbnail.find('.BGSampleTitle');
93 clipper.appendChild(titleElement.get(0));111 clipper.appendChild(titleElement.get(0));
94 thumbnail.append(clipper);112 thumbnail.append(clipper);
@@ -548,6 +566,8 @@ function renderChatBackgrounds(backgrounds) {
548}566}
549567
550export async function getBackgrounds() {568export async function getBackgrounds() {
569 const metadataPromise = preloadImageMetadata();
570
551 const response = await fetch('/api/backgrounds/all', {571 const response = await fetch('/api/backgrounds/all', {
552 method: 'POST',572 method: 'POST',
553 headers: getRequestHeaders(),573 headers: getRequestHeaders(),
@@ -557,11 +577,38 @@ export async function getBackgrounds() {
557 const { images, config } = await response.json();577 const { images, config } = await response.json();
558 Object.assign(THUMBNAIL_CONFIG, config);578 Object.assign(THUMBNAIL_CONFIG, config);
559579
580 await metadataPromise;
581
560 renderSystemBackgrounds(images);582 renderSystemBackgrounds(images);
561 highlightSelectedBackground();583 highlightSelectedBackground();
562 }584 }
563}585}
564586
587/**
588 * Preloads all image metadata to use dominant colors as placeholders.
589 * @return {Promise<void>}
590 */
591async 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
565function activateLazyLoader() {612function activateLazyLoader() {
566 // Disconnect previous observer to prevent memory leaks613 // Disconnect previous observer to prevent memory leaks
567 if (lazyLoadObserver) {614 if (lazyLoadObserver) {
src/endpoints/backgrounds.js+24 -2
@@ -5,13 +5,13 @@ import express from 'express';
5import sanitize from 'sanitize-filename';5import sanitize from 'sanitize-filename';
66
7import { invalidateThumbnail } from './thumbnails.js';7import { invalidateThumbnail } from './thumbnails.js';
8import { thumbnailDimensions } from './image-metadata.js';8import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js';
9import { getImages } from '../util.js';9import { getImages } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111
12export const router = express.Router();12export const router = express.Router();
1313
14router.post('/all', function (request, response) {14router.post('/all', async function (request, response) {
15 const images = getImages(request.user.directories.backgrounds);15 const images = getImages(request.user.directories.backgrounds);
16 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };16 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };
17 response.json({ images, config });17 response.json({ images, config });
@@ -34,6 +34,13 @@ router.post('/delete', getFileNameValidationFunction('bg'), function (request, r
3434
35 fs.unlinkSync(fileName);35 fs.unlinkSync(fileName);
36 invalidateThumbnail(request.user.directories, 'bg', request.body.bg);36 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
37 return response.send('ok');44 return response.send('ok');
38});45});
3946
@@ -56,6 +63,14 @@ router.post('/rename', function (request, response) {
56 fs.copyFileSync(oldFileName, newFileName);63 fs.copyFileSync(oldFileName, newFileName);
57 fs.unlinkSync(oldFileName);64 fs.unlinkSync(oldFileName);
58 invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);65 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
59 return response.send('ok');74 return response.send('ok');
60});75});
6176
@@ -69,6 +84,13 @@ router.post('/upload', function (request, response) {
69 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));84 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
70 fs.unlinkSync(img_path);85 fs.unlinkSync(img_path);
71 invalidateThumbnail(request.user.directories, 'bg', filename);86 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
72 response.send(filename);94 response.send(filename);
73 } catch (err) {95 } catch (err) {
74 console.error(err);96 console.error(err);
src/endpoints/image-metadata.js+82 -5
@@ -10,7 +10,7 @@ import { imageSize } from 'image-size';
10import writeFileAtomic from 'write-file-atomic';10import writeFileAtomic from 'write-file-atomic';
11import express from 'express';11import express from 'express';
12import { Jimp } from '../jimp.js';12import { Jimp } from '../jimp.js';
13import { getConfigValue, isPathUnderParent } from '../util.js';13import { getConfigValue, getImages, isPathUnderParent } from '../util.js';
1414
15export const METADATA_FILE = 'image-metadata.json';15export const METADATA_FILE = 'image-metadata.json';
1616
@@ -188,13 +188,14 @@ export async function writeMetadataIndex(userDataRoot, metadata) {
188 * @param {string} userDataRoot - Path to the user data directory root188 * @param {string} userDataRoot - Path to the user data directory root
189 * @param {string[]} relativePaths - Array of relative paths from userDataRoot189 * @param {string[]} relativePaths - Array of relative paths from userDataRoot
190 * @param {ThumbnailType} type - The thumbnail type for resolution calculation.190 * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
191 * @returns {Promise<Object.<string, ImageMetadata>>} Map of relativePath to metadata191 * @returns {Promise<{results: Object.<string, ImageMetadata>, generatedCount: number}>} Results map and count of newly generated
192 */192 */
193export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) {193export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) {
194 /** @type {Object.<string, ImageMetadata>} */194 /** @type {Object.<string, ImageMetadata>} */
195 const results = {};195 const results = {};
196 const index = await readMetadataIndex(userDataRoot);196 const index = await readMetadataIndex(userDataRoot);
197 let indexModified = false;197 let indexModified = false;
198 let generatedCount = 0;
198199
199 for (const relativePath of relativePaths) {200 for (const relativePath of relativePaths) {
200 // Normalize the path to use forward slashes for consistent keys201 // Normalize the path to use forward slashes for consistent keys
@@ -230,6 +231,7 @@ export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, ty
230 index.images[posixPath] = metadata;231 index.images[posixPath] = metadata;
231 results[relativePath] = metadata;232 results[relativePath] = metadata;
232 indexModified = true;233 indexModified = true;
234 generatedCount++;
233 } catch (error) {235 } catch (error) {
234 console.warn(`[ImageMetadata] Failed to generate metadata for ${relativePath}:`, error.message);236 console.warn(`[ImageMetadata] Failed to generate metadata for ${relativePath}:`, error.message);
235 }237 }
@@ -240,7 +242,7 @@ export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, ty
240 await writeMetadataIndex(userDataRoot, index);242 await writeMetadataIndex(userDataRoot, index);
241 }243 }
242244
243 return results;245 return { results, generatedCount };
244}246}
245247
246/**248/**
@@ -317,6 +319,52 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
317 return orphanedPaths;319 return orphanedPaths;
318}320}
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 */
329export 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 */
358export 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
320export const router = express.Router();368export const router = express.Router();
321369
322/**370/**
@@ -353,7 +401,7 @@ router.post('/', async function (request, response) {
353 return response.status(404).json({ error: 'File not found.' });401 return response.status(404).json({ error: 'File not found.' });
354 }402 }
355403
356 const metadataResults = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type);404 const { results: metadataResults } = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type);
357 const metadata = metadataResults[relativePath];405 const metadata = metadataResults[relativePath];
358406
359 if (!metadata) {407 if (!metadata) {
@@ -380,7 +428,7 @@ router.post('/', async function (request, response) {
380 }428 }
381429
382 // Process all valid paths in a single batch430 // Process all valid paths in a single batch
383 const batchMetadata = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type);431 const { results: batchMetadata } = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type);
384432
385 for (const relativePath of validPaths) {433 for (const relativePath of validPaths) {
386 if (batchMetadata[relativePath]) {434 if (batchMetadata[relativePath]) {
@@ -402,6 +450,35 @@ router.post('/', async function (request, response) {
402});450});
403451
404/**452/**
453 * POST /api/image-metadata/all
454 * Get all metadata from the index.
455 * @body {string} [prefix] - Optional path prefix to filter results
456 */
457router.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/**
405 * POST /api/image-metadata/cleanup482 * POST /api/image-metadata/cleanup
406 * Clean up orphaned metadata entries (files that no longer exist).483 * Clean up orphaned metadata entries (files that no longer exist).
407 */484 */
src/server-main.js+4 -0
@@ -69,6 +69,7 @@ import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } fro
69import { diskCache } from './endpoints/characters.js';69import { diskCache } from './endpoints/characters.js';
70import { migrateFlatSecrets } from './endpoints/secrets.js';70import { migrateFlatSecrets } from './endpoints/secrets.js';
71import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js';71import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js';
72import { initializeAllUserMetadata } from './endpoints/image-metadata.js';
7273
73// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.74// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
74// https://github.com/nodejs/node/issues/47822#issuecomment-156470887075// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -276,6 +277,9 @@ async function preSetupTasks() {
276 await settingsInit();277 await settingsInit();
277 await statsInit();278 await statsInit();
278279
280 // Initialize image metadata
281 await initializeAllUserMetadata(directories);
282
279 const pluginsDirectory = path.join(serverDirectory, 'plugins');283 const pluginsDirectory = path.join(serverDirectory, 'plugins');
280 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);284 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
281 const consoleTitle = process.title;285 const consoleTitle = process.title;