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 | * Background source types. | 51 | * Background source types. |
| 46 | * @readonly | 52 | * @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; |
| 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 | 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 | } |
| 549 | 567 | ||
| 550 | export async function getBackgrounds() { | 568 | export 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); |
| 559 | 579 | ||
| 580 | await metadataPromise; | ||
| 581 | |||
| 560 | renderSystemBackgrounds(images); | 582 | renderSystemBackgrounds(images); |
| 561 | highlightSelectedBackground(); | 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 | function activateLazyLoader() { | 612 | function activateLazyLoader() { |
| 566 | // Disconnect previous observer to prevent memory leaks | 613 | // Disconnect previous observer to prevent memory leaks |
| 567 | if (lazyLoadObserver) { | 614 | if (lazyLoadObserver) { |
| @@ -5,13 +5,13 @@ import express from 'express'; | |||
| 5 | import sanitize from 'sanitize-filename'; | 5 | import sanitize from 'sanitize-filename'; |
| 6 | 6 | ||
| 7 | import { invalidateThumbnail } from './thumbnails.js'; | 7 | import { invalidateThumbnail } from './thumbnails.js'; |
| 8 | import { thumbnailDimensions } from './image-metadata.js'; | 8 | import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js'; |
| 9 | import { getImages } from '../util.js'; | 9 | import { getImages } from '../util.js'; |
| 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | 11 | ||
| 12 | export const router = express.Router(); | 12 | export const router = express.Router(); |
| 13 | 13 | ||
| 14 | router.post('/all', function (request, response) { | 14 | router.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 | |||
| 34 | 34 | ||
| 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 | }); |
| 39 | 46 | ||
| @@ -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 | }); |
| 61 | 76 | ||
| @@ -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); |
| @@ -10,7 +10,7 @@ import { imageSize } from 'image-size'; | |||
| 10 | import writeFileAtomic from 'write-file-atomic'; | 10 | import writeFileAtomic from 'write-file-atomic'; |
| 11 | import express from 'express'; | 11 | import express from 'express'; |
| 12 | import { Jimp } from '../jimp.js'; | 12 | import { Jimp } from '../jimp.js'; |
| 13 | import { getConfigValue, isPathUnderParent } from '../util.js'; | 13 | import { getConfigValue, getImages, isPathUnderParent } from '../util.js'; |
| 14 | 14 | ||
| 15 | export const METADATA_FILE = 'image-metadata.json'; | 15 | export const METADATA_FILE = 'image-metadata.json'; |
| 16 | 16 | ||
| @@ -188,13 +188,14 @@ export async function writeMetadataIndex(userDataRoot, metadata) { | |||
| 188 | * @param {string} userDataRoot - Path to the user data directory root | 188 | * @param {string} userDataRoot - Path to the user data directory root |
| 189 | * @param {string[]} relativePaths - Array of relative paths from userDataRoot | 189 | * @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 metadata | 191 | * @returns {Promise<{results: Object.<string, ImageMetadata>, generatedCount: number}>} Results map and count of newly generated |
| 192 | */ | 192 | */ |
| 193 | export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) { | 193 | export 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; | ||
| 198 | 199 | ||
| 199 | for (const relativePath of relativePaths) { | 200 | for (const relativePath of relativePaths) { |
| 200 | // Normalize the path to use forward slashes for consistent keys | 201 | // 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 | } |
| 242 | 244 | ||
| 243 | return results; | 245 | return { results, generatedCount }; |
| 244 | } | 246 | } |
| 245 | 247 | ||
| 246 | /** | 248 | /** |
| @@ -317,6 +319,52 @@ export async function cleanupOrphanedMetadata(userDataRoot) { | |||
| 317 | return orphanedPaths; | 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 | export const router = express.Router(); | 368 | export const router = express.Router(); |
| 321 | 369 | ||
| 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 | } |
| 355 | 403 | ||
| 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]; |
| 358 | 406 | ||
| 359 | if (!metadata) { | 407 | if (!metadata) { |
| @@ -380,7 +428,7 @@ router.post('/', async function (request, response) { | |||
| 380 | } | 428 | } |
| 381 | 429 | ||
| 382 | // Process all valid paths in a single batch | 430 | // 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); |
| 384 | 432 | ||
| 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 | }); |
| 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 | * POST /api/image-metadata/cleanup | 482 | * 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 | */ |
| @@ -69,6 +69,7 @@ import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } fro | |||
| 69 | import { diskCache } from './endpoints/characters.js'; | 69 | import { diskCache } from './endpoints/characters.js'; |
| 70 | import { migrateFlatSecrets } from './endpoints/secrets.js'; | 70 | import { migrateFlatSecrets } from './endpoints/secrets.js'; |
| 71 | import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js'; | 71 | import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js'; |
| 72 | import { initializeAllUserMetadata } from './endpoints/image-metadata.js'; | ||
| 72 | 73 | ||
| 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-1564708870 | 75 | // 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(); |
| 278 | 279 | ||
| 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; |