Fix APNG thumbnail handling and improve animated format detection (#5113) * check png for apng headers * isAnimated flag * refactor: centralize background animation extension checks * refactor: scope animated extension dedupe to backgrounds * remove precompute from startup * Fix animation preference not being loaded, fix type-check of customInputs * Fix eslint * Fix sort on removal type * Update metadata before returning from endpoint on CRUD operations * Remove race condition in metadata load * Load metadata after loading the list --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -41,6 +41,8 @@ const THUMBNAIL_CONFIG = { | ||
| 41 | 41 | height: 90, |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | +const ANIMATED_BACKGROUND_EXTENSIONS = ['mp4', 'webp', 'gif', 'apng']; | |
| 45 | + | |
| 44 | 46 | /** |
| 45 | 47 | * Cache for image metadata. |
| 46 | 48 | * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>} |
| @@ -88,7 +90,7 @@ let lazyLoadObserver = null; | ||
| 88 | 90 | /** |
| 89 | 91 | * Cache for the current list of system background filenames. |
| 90 | 92 | * Used to re-sort backgrounds without refetching from the server. |
| 91 | 93 | * @type {Array<{filename: string[], isAnimated: boolean}>} |
| 92 | 94 | */ |
| 93 | 95 | let cachedSystemBackgrounds = []; |
| 94 | 96 | |
| @@ -136,12 +138,13 @@ function sortBackgrounds(backgrounds, isCustom = false) { | ||
| 136 | 138 | |
| 137 | 139 | /** |
| 138 | 140 | * Creates a single thumbnail DOM element. The CSS now handles all sizing. |
| 139 | 141 | * @param {object} imageData - Data for the image (filename, isCustom, isAnimated). |
| 140 | 142 | * @returns {HTMLElement} The created thumbnail element. |
| 141 | 143 | */ |
| 142 | 144 | function createThumbnailElement(imageData) { |
| 143 | 145 | const bg = imageData.filename; |
| 144 | 146 | const isCustom = imageData.isCustom; |
| 147 | + const isAnimated = imageData.isAnimated ?? false; | |
| 145 | 148 | |
| 146 | 149 | const thumbnail = $('#background_template .bg_example').clone(); |
| 147 | 150 | |
| @@ -172,6 +175,7 @@ function createThumbnailElement(imageData) { | ||
| 172 | 175 | thumbnail.attr('title', title); |
| 173 | 176 | thumbnail.attr('bgfile', bg); |
| 174 | 177 | thumbnail.attr('custom', String(isCustom)); |
| 178 | + thumbnail.attr('animated', String(isAnimated)); | |
| 175 | 179 | thumbnail.data('url', url); |
| 176 | 180 | titleElement.text(friendlyTitle); |
| 177 | 181 | |
| @@ -216,6 +220,7 @@ export function loadBackgroundSettings(settings) { | ||
| 216 | 220 | } |
| 217 | 221 | background_settings.thumbnailColumns = columns; |
| 218 | 222 | background_settings.sortOrder = backgroundSettings.sortOrder; |
| 223 | + background_settings.animation = backgroundSettings.animation; | |
| 219 | 224 | applyThumbnailColumns(background_settings.thumbnailColumns); |
| 220 | 225 | |
| 221 | 226 | setBackground(backgroundSettings.name, backgroundSettings.url); |
| @@ -498,14 +503,13 @@ async function onDeleteBackgroundClick(e) { | ||
| 498 | 503 | const url = bgToDelete.data('url'); |
| 499 | 504 | const isCustom = bgToDelete.attr('custom') === 'true'; |
| 500 | 505 | const deleteFromServerId = 'delete_bg_from_server'; |
| 501 | - const customInputs = [ | |
| 506 | + /** @type {import('./popup.js').CustomPopupInput[]} */ | |
| 502 | - { | |
| 507 | + const customInputs = [{ | |
| 503 | 508 | type: 'checkbox', |
| 504 | 509 | label: t`Also delete file from server`, |
| 505 | 510 | id: deleteFromServerId, |
| 506 | 511 | defaultState: true, |
| 507 | - }, | |
| 512 | + }]; | |
| 508 | - ]; | |
| 509 | 513 | let deleteFromServer = false; |
| 510 | 514 | const confirm = await Popup.show.confirm(t`Delete the background?`, null, { |
| 511 | 515 | customInputs: isCustom ? customInputs : [], |
| @@ -522,7 +526,7 @@ async function onDeleteBackgroundClick(e) { | ||
| 522 | 526 | if (!isCustom) { |
| 523 | 527 | await delBackground(bg); |
| 524 | 528 | // Remove from cache to prevent reappearing on sort change |
| 525 | 529 | const cacheIndex = cachedSystemBackgrounds.indexOffindIndex(s => s.filename === bg); |
| 526 | 530 | if (cacheIndex !== -1) { |
| 527 | 531 | cachedSystemBackgrounds.splice(cacheIndex, 1); |
| 528 | 532 | } |
| @@ -605,7 +609,7 @@ async function autoBackgroundCommand() { | ||
| 605 | 609 | |
| 606 | 610 | /** |
| 607 | 611 | * Renders the system backgrounds gallery. |
| 608 | 612 | * @param {Array<{filename: string[], isAnimated: boolean}>} [backgrounds] - Optional filtered list of backgrounds with metadata. |
| 609 | 613 | */ |
| 610 | 614 | function renderSystemBackgrounds(backgrounds) { |
| 611 | 615 | const sourceList = backgrounds || []; |
| @@ -614,9 +618,11 @@ function renderSystemBackgrounds(backgrounds) { | ||
| 614 | 618 | |
| 615 | 619 | if (sourceList.length === 0) return; |
| 616 | 620 | |
| 617 | 621 | const sortedList = sortBackgrounds(sourceList.map(bg => bg.filename), false); |
| 618 | - sortedList.forEach(bg => { | |
| 622 | + const metadataByFilename = new Map(sourceList.map(bg => [bg.filename, bg])); | |
| 619 | - const imageData = { filename: bg, isCustom: false }; | |
| 623 | + sortedList.forEach(filename => { | |
| 624 | + const bg = metadataByFilename.get(filename); | |
| 625 | + const imageData = { filename, isCustom: false, isAnimated: bg?.isAnimated ?? false }; | |
| 620 | 626 | const thumbnail = createThumbnailElement(imageData); |
| 621 | 627 | container.append(thumbnail); |
| 622 | 628 | }); |
| @@ -638,7 +644,9 @@ function renderChatBackgrounds(backgrounds) { | ||
| 638 | 644 | |
| 639 | 645 | const sortedList = sortBackgrounds(sourceList, true); |
| 640 | 646 | sortedList.forEach(bg => { |
| 641 | - const imageData = { filename: bg, isCustom: true }; | |
| 647 | + // For custom backgrounds, infer isAnimated from extension since we don't have server metadata | |
| 648 | + const isAnimated = isAnimatedBackgroundExtension(bg); | |
| 649 | + const imageData = { filename: bg, isCustom: true, isAnimated }; | |
| 642 | 650 | const thumbnail = createThumbnailElement(imageData); |
| 643 | 651 | container.append(thumbnail); |
| 644 | 652 | }); |
| @@ -647,8 +655,6 @@ function renderChatBackgrounds(backgrounds) { | ||
| 647 | 655 | } |
| 648 | 656 | |
| 649 | 657 | export async function getBackgrounds() { |
| 650 | - const metadataPromise = preloadImageMetadata(); | |
| 651 | - | |
| 652 | 658 | const response = await fetch('/api/backgrounds/all', { |
| 653 | 659 | method: 'POST', |
| 654 | 660 | headers: getRequestHeaders(), |
| @@ -657,10 +663,8 @@ export async function getBackgrounds() { | ||
| 657 | 663 | if (response.ok) { |
| 658 | 664 | const { images, config } = await response.json(); |
| 659 | 665 | Object.assign(THUMBNAIL_CONFIG, config); |
| 660 | - | |
| 661 | 666 | cachedSystemBackgrounds = images; |
| 662 | - | |
| 667 | + await preloadImageMetadata(); | |
| 663 | - await metadataPromise; | |
| 664 | 668 | |
| 665 | 669 | renderSystemBackgrounds(images); |
| 666 | 670 | highlightSelectedBackground(); |
| @@ -716,7 +720,8 @@ function activateLazyLoader() { | ||
| 716 | 720 | if (parentThumbnail) { |
| 717 | 721 | const bg = parentThumbnail.getAttribute('bgfile'); |
| 718 | 722 | const isCustom = parentThumbnail.getAttribute('custom') === 'true'; |
| 719 | - resolveImageUrl(bg, isCustom) | |
| 723 | + const isAnimated = parentThumbnail.getAttribute('animated') === 'true'; | |
| 724 | + resolveImageUrl(bg, isCustom, isAnimated) | |
| 720 | 725 | .then(url => { clipper.style.backgroundImage = url; }) |
| 721 | 726 | .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; }); |
| 722 | 727 | } |
| @@ -745,16 +750,26 @@ function generateUrlParameter(bg, isCustom) { | ||
| 745 | 750 | return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`; |
| 746 | 751 | } |
| 747 | 752 | |
| 753 | +function isAnimatedBackgroundExtension(fileName) { | |
| 754 | + const fileExtension = fileName.split('.').pop().toLowerCase(); | |
| 755 | + return ANIMATED_BACKGROUND_EXTENSIONS.includes(fileExtension); | |
| 756 | +} | |
| 757 | + | |
| 748 | 758 | /** |
| 749 | 759 | * Resolves the image URL for the background. |
| 750 | 760 | * @param {string} bg Background file name |
| 751 | 761 | * @param {boolean} isCustom Is a custom background |
| 762 | + * @param {boolean|null} [isAnimated=null] Is the background animated (from metadata). If null, infers from extension. | |
| 752 | 763 | * @returns {Promise<string>} CSS URL of the background |
| 753 | 764 | */ |
| 754 | 765 | async function resolveImageUrl(bg, isCustom, isAnimated = null) { |
| 755 | - const fileExtension = bg.split('.').pop().toLowerCase(); | |
| 766 | + // If isAnimated is not provided (null), fall back to extension-based heuristic | |
| 756 | - const isAnimated = ['mp4', 'webp'].includes(fileExtension); | |
| 767 | + let animated = isAnimated; | |
| 757 | - const thumbnailUrl = isAnimated && !background_settings.animation | |
| 768 | + if (animated === null) { | |
| 769 | + animated = isAnimatedBackgroundExtension(bg); | |
| 770 | + } | |
| 771 | + | |
| 772 | + const thumbnailUrl = animated && !background_settings.animation | |
| 758 | 773 | ? await getThumbnailFromStorage(bg, isCustom) |
| 759 | 774 | : isCustom |
| 760 | 775 | ? bg |
| @@ -12,12 +12,33 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js | ||
| 12 | 12 | export const router = express.Router(); |
| 13 | 13 | |
| 14 | 14 | router.post('/all', async function (request, response) { |
| 15 | + try { | |
| 15 | 16 | const images = getImages(request.user.directories.backgrounds); |
| 16 | 17 | const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] }; |
| 17 | - response.json({ images, config }); | |
| 18 | + | |
| 19 | + // Get metadata for all images to provide isAnimated flag to client | |
| 20 | + const relativePaths = images.map(img => path.join('backgrounds', img)); | |
| 21 | + const { results: metadataMap } = await getOrGenerateMetadataBatch(request.user.directories.root, relativePaths, 'bg'); | |
| 22 | + | |
| 23 | + // Build response with metadata for each image | |
| 24 | + const imagesWithMetadata = images.map(img => { | |
| 25 | + const relativePath = path.join('backgrounds', img); | |
| 26 | + const metadata = metadataMap[relativePath]; | |
| 27 | + return { | |
| 28 | + filename: img, | |
| 29 | + isAnimated: metadata?.isAnimated ?? false, | |
| 30 | + }; | |
| 31 | + }); | |
| 32 | + | |
| 33 | + response.json({ images: imagesWithMetadata, config }); | |
| 34 | + } catch (error) { | |
| 35 | + console.error('[Backgrounds] Error fetching backgrounds:', error); | |
| 36 | + response.status(500).json({ error: 'Failed to fetch backgrounds' }); | |
| 37 | + } | |
| 18 | 38 | }); |
| 19 | 39 | |
| 20 | 40 | router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) { |
| 41 | + try { | |
| 21 | 42 | if (!request.body) return response.sendStatus(400); |
| 22 | 43 | |
| 23 | 44 | if (request.body.bg !== sanitize(request.body.bg)) { |
| @@ -37,14 +58,19 @@ router.post('/delete', getFileNameValidationFunction('bg'), function (request, r | ||
| 37 | 58 | |
| 38 | 59 | // Remove metadata for deleted image |
| 39 | 60 | const relativePath = path.join('backgrounds', request.body.bg); |
| 40 | 61 | await removeMetadata(request.user.directories.root, relativePath).catch(err => { |
| 41 | 62 | console.warn('[Backgrounds] Failed to remove metadata:', err.message); |
| 42 | 63 | }); |
| 43 | 64 | |
| 44 | 65 | return response.send('ok'); |
| 66 | + } catch (err) { | |
| 67 | + console.error(err); | |
| 68 | + response.sendStatus(500); | |
| 69 | + } | |
| 45 | 70 | }); |
| 46 | 71 | |
| 47 | 72 | router.post('/rename', async function (request, response) { |
| 73 | + try { | |
| 48 | 74 | if (!request.body) return response.sendStatus(400); |
| 49 | 75 | |
| 50 | 76 | const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg)); |
| @@ -67,27 +93,31 @@ router.post('/rename', function (request, response) { | ||
| 67 | 93 | // Update metadata for renamed image |
| 68 | 94 | const oldRelativePath = path.join('backgrounds', request.body.old_bg); |
| 69 | 95 | const newRelativePath = path.join('backgrounds', request.body.new_bg); |
| 70 | 96 | await renameMetadata(request.user.directories.root, oldRelativePath, newRelativePath).catch(err => { |
| 71 | 97 | console.warn('[Backgrounds] Failed to rename metadata:', err.message); |
| 72 | 98 | }); |
| 73 | 99 | |
| 74 | 100 | return response.send('ok'); |
| 101 | + } catch (err) { | |
| 102 | + console.error(err); | |
| 103 | + response.sendStatus(500); | |
| 104 | + } | |
| 75 | 105 | }); |
| 76 | 106 | |
| 77 | 107 | router.post('/upload', async function (request, response) { |
| 108 | + try { | |
| 78 | 109 | if (!request.body || !request.file) return response.sendStatus(400); |
| 79 | 110 | |
| 80 | 111 | const img_path = path.join(request.file.destination, request.file.filename); |
| 81 | 112 | const filename = sanitize(request.file.originalname); |
| 82 | 113 | |
| 83 | - try { | |
| 84 | 114 | fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename)); |
| 85 | 115 | fs.unlinkSync(img_path); |
| 86 | 116 | invalidateThumbnail(request.user.directories, 'bg', filename); |
| 87 | 117 | |
| 88 | 118 | // Generate metadata for the new image |
| 89 | 119 | const relativePath = path.join('backgrounds', filename); |
| 90 | 120 | await getOrGenerateMetadataBatch(request.user.directories.root, [relativePath], 'bg').catch(err => { |
| 91 | 121 | console.warn('[Backgrounds] Failed to generate metadata for upload:', err.message); |
| 92 | 122 | }); |
| 93 | 123 | |
| @@ -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 | |
| @@ -319,51 +319,6 @@ export async function cleanupOrphanedMetadata(userDataRoot) { | ||
| 319 | 319 | return orphanedPaths; |
| 320 | 320 | } |
| 321 | 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 | 322 | |
| 368 | 323 | export const router = express.Router(); |
| 369 | 324 | |
| @@ -8,7 +8,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; | ||
| 8 | 8 | import { imageSize as sizeOf } from 'image-size'; |
| 9 | 9 | |
| 10 | 10 | import { getConfigValue, invalidateFirefoxCache } from '../util.js'; |
| 11 | 11 | import { getThumbnailResolution, isAnimatedWebP, isAnimatedApng, thumbnailDimensions as dimensions, isAnimatedApng } from './image-metadata.js'; |
| 12 | 12 | import { ResizeStrategy } from '@jimp/plugin-resize'; |
| 13 | 13 | |
| 14 | 14 | export const publicRouter = express.Router(); |
| @@ -69,7 +69,6 @@ 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'; | |
| 73 | 72 | |
| 74 | 73 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. |
| 75 | 74 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 |
| @@ -300,9 +299,6 @@ async function preSetupTasks() { | ||
| 300 | 299 | await settingsInit(); |
| 301 | 300 | await statsInit(); |
| 302 | 301 | |
| 303 | - // Initialize image metadata | |
| 304 | - await initializeAllUserMetadata(directories); | |
| 305 | - | |
| 306 | 302 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); |
| 307 | 303 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); |
| 308 | 304 | const consoleTitle = process.title; |