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>
Signed| @@ -42,6 +42,12 @@ const THUMBNAIL_CONFIG = { | ||
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | 44 | /** |
| 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 | +/** | |
| 45 | 51 | * Background source types. |
| 46 | 52 | * @readonly |
| 47 | 53 | * @enum {number} |
| @@ -89,6 +95,18 @@ function createThumbnailElement(imageData) { | ||
| 89 | 95 | clipper.className = 'thumbnail-clipper lazy-load-background'; |
| 90 | 96 | clipper.style.backgroundImage = PLACEHOLDER_IMAGE; |
| 91 | 97 | |
| 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 | 110 | const titleElement = thumbnail.find('.BGSampleTitle'); |
| 93 | 111 | clipper.appendChild(titleElement.get(0)); |
| 94 | 112 | thumbnail.append(clipper); |
| @@ -548,6 +566,8 @@ function renderChatBackgrounds(backgrounds) { | ||
| 548 | 566 | } |
| 549 | 567 | |
| 550 | 568 | export async function getBackgrounds() { |
| 569 | + const metadataPromise = preloadImageMetadata(); | |
| 570 | + | |
| 551 | 571 | const response = await fetch('/api/backgrounds/all', { |
| 552 | 572 | method: 'POST', |
| 553 | 573 | headers: getRequestHeaders(), |
| @@ -557,11 +577,38 @@ export async function getBackgrounds() { | ||
| 557 | 577 | const { images, config } = await response.json(); |
| 558 | 578 | Object.assign(THUMBNAIL_CONFIG, config); |
| 559 | 579 | |
| 580 | + await metadataPromise; | |
| 581 | + | |
| 560 | 582 | renderSystemBackgrounds(images); |
| 561 | 583 | highlightSelectedBackground(); |
| 562 | 584 | } |
| 563 | 585 | } |
| 564 | 586 | |
| 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 | + | |
| 565 | 612 | function activateLazyLoader() { |
| 566 | 613 | // Disconnect previous observer to prevent memory leaks |
| 567 | 614 | if (lazyLoadObserver) { |
| @@ -5,13 +5,13 @@ import express from 'express'; | ||
| 5 | 5 | import sanitize from 'sanitize-filename'; |
| 6 | 6 | |
| 7 | 7 | import { invalidateThumbnail } from './thumbnails.js'; |
| 8 | 8 | import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js'; |
| 9 | 9 | import { getImages } from '../util.js'; |
| 10 | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | 11 | |
| 12 | 12 | export const router = express.Router(); |
| 13 | 13 | |
| 14 | 14 | router.post('/all', async function (request, response) { |
| 15 | 15 | const images = getImages(request.user.directories.backgrounds); |
| 16 | 16 | const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] }; |
| 17 | 17 | response.json({ images, config }); |
| @@ -34,6 +34,13 @@ router.post('/delete', getFileNameValidationFunction('bg'), function (request, r | ||
| 34 | 34 | |
| 35 | 35 | fs.unlinkSync(fileName); |
| 36 | 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 | 44 | return response.send('ok'); |
| 38 | 45 | }); |
| 39 | 46 | |
| @@ -56,6 +63,14 @@ router.post('/rename', function (request, response) { | ||
| 56 | 63 | fs.copyFileSync(oldFileName, newFileName); |
| 57 | 64 | fs.unlinkSync(oldFileName); |
| 58 | 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 | 74 | return response.send('ok'); |
| 60 | 75 | }); |
| 61 | 76 | |
| @@ -69,6 +84,13 @@ router.post('/upload', function (request, response) { | ||
| 69 | 84 | fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename)); |
| 70 | 85 | fs.unlinkSync(img_path); |
| 71 | 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 | 94 | response.send(filename); |
| 73 | 95 | } catch (err) { |
| 74 | 96 | console.error(err); |
| @@ -10,7 +10,7 @@ import { imageSize } from 'image-size'; | ||
| 10 | 10 | import writeFileAtomic from 'write-file-atomic'; |
| 11 | 11 | import express from 'express'; |
| 12 | 12 | import { Jimp } from '../jimp.js'; |
| 13 | 13 | import { getConfigValue, getImages, isPathUnderParent } from '../util.js'; |
| 14 | 14 | |
| 15 | 15 | export const METADATA_FILE = 'image-metadata.json'; |
| 16 | 16 | |
| @@ -188,13 +188,14 @@ export async function writeMetadataIndex(userDataRoot, metadata) { | ||
| 188 | 188 | * @param {string} userDataRoot - Path to the user data directory root |
| 189 | 189 | * @param {string[]} relativePaths - Array of relative paths from userDataRoot |
| 190 | 190 | * @param {ThumbnailType} type - The thumbnail type for resolution calculation. |
| 191 | 191 | * @returns {Promise<{results: Object.<string, ImageMetadata>, generatedCount: number}>} MapResults ofmap relativePathand tocount metadataof newly generated |
| 192 | 192 | */ |
| 193 | 193 | export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) { |
| 194 | 194 | /** @type {Object.<string, ImageMetadata>} */ |
| 195 | 195 | const results = {}; |
| 196 | 196 | const index = await readMetadataIndex(userDataRoot); |
| 197 | 197 | let indexModified = false; |
| 198 | + let generatedCount = 0; | |
| 198 | 199 | |
| 199 | 200 | for (const relativePath of relativePaths) { |
| 200 | 201 | // Normalize the path to use forward slashes for consistent keys |
| @@ -230,6 +231,7 @@ export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, ty | ||
| 230 | 231 | index.images[posixPath] = metadata; |
| 231 | 232 | results[relativePath] = metadata; |
| 232 | 233 | indexModified = true; |
| 234 | + generatedCount++; | |
| 233 | 235 | } catch (error) { |
| 234 | 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 | 242 | await writeMetadataIndex(userDataRoot, index); |
| 241 | 243 | } |
| 242 | 244 | |
| 243 | 245 | return { results, generatedCount }; |
| 244 | 246 | } |
| 245 | 247 | |
| 246 | 248 | /** |
| @@ -317,6 +319,52 @@ export async function cleanupOrphanedMetadata(userDataRoot) { | ||
| 317 | 319 | return orphanedPaths; |
| 318 | 320 | } |
| 319 | 321 | |
| 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 | + | |
| 320 | 368 | export const router = express.Router(); |
| 321 | 369 | |
| 322 | 370 | /** |
| @@ -353,7 +401,7 @@ router.post('/', async function (request, response) { | ||
| 353 | 401 | return response.status(404).json({ error: 'File not found.' }); |
| 354 | 402 | } |
| 355 | 403 | |
| 356 | 404 | const { results: metadataResults } = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type); |
| 357 | 405 | const metadata = metadataResults[relativePath]; |
| 358 | 406 | |
| 359 | 407 | if (!metadata) { |
| @@ -380,7 +428,7 @@ router.post('/', async function (request, response) { | ||
| 380 | 428 | } |
| 381 | 429 | |
| 382 | 430 | // Process all valid paths in a single batch |
| 383 | 431 | const { results: batchMetadata } = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type); |
| 384 | 432 | |
| 385 | 433 | for (const relativePath of validPaths) { |
| 386 | 434 | if (batchMetadata[relativePath]) { |
| @@ -402,6 +450,35 @@ router.post('/', async function (request, response) { | ||
| 402 | 450 | }); |
| 403 | 451 | |
| 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 | + */ | |
| 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 | +/** | |
| 405 | 482 | * POST /api/image-metadata/cleanup |
| 406 | 483 | * Clean up orphaned metadata entries (files that no longer exist). |
| 407 | 484 | */ |
| @@ -69,6 +69,7 @@ import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } fro | ||
| 69 | 69 | import { diskCache } from './endpoints/characters.js'; |
| 70 | 70 | import { migrateFlatSecrets } from './endpoints/secrets.js'; |
| 71 | 71 | import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js'; |
| 72 | +import { initializeAllUserMetadata } from './endpoints/image-metadata.js'; | |
| 72 | 73 | |
| 73 | 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 | 75 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 |
| @@ -276,6 +277,9 @@ async function preSetupTasks() { | ||
| 276 | 277 | await settingsInit(); |
| 277 | 278 | await statsInit(); |
| 278 | 279 | |
| 280 | + // Initialize image metadata | |
| 281 | + await initializeAllUserMetadata(directories); | |
| 282 | + | |
| 279 | 283 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); |
| 280 | 284 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); |
| 281 | 285 | const consoleTitle = process.title; |