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>

8aaaab37b7b43381ca0867625ed07b839eba8c67

Lucas Scala <123923688+Vibecoder9000@users.noreply.github.com>

Signed
5 files changed, +77 -81Showing whitespace changes
public/scripts/backgrounds.js+37 -22
@@ -41,6 +41,8 @@ const THUMBNAIL_CONFIG = {
4141 height: 90,
4242};
4343
44+const ANIMATED_BACKGROUND_EXTENSIONS = ['mp4', 'webp', 'gif', 'apng'];
45+
4446/**
4547 * Cache for image metadata.
4648 * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
@@ -88,7 +90,7 @@ let lazyLoadObserver = null;
8890/**
8991 * Cache for the current list of system background filenames.
9092 * Used to re-sort backgrounds without refetching from the server.
9193 * @type {Array<{filename: string[], isAnimated: boolean}>}
9294 */
9395let cachedSystemBackgrounds = [];
9496
@@ -136,12 +138,13 @@ function sortBackgrounds(backgrounds, isCustom = false) {
136138
137139/**
138140 * Creates a single thumbnail DOM element. The CSS now handles all sizing.
139141 * @param {object} imageData - Data for the image (filename, isCustom, isAnimated).
140142 * @returns {HTMLElement} The created thumbnail element.
141143 */
142144function createThumbnailElement(imageData) {
143145 const bg = imageData.filename;
144146 const isCustom = imageData.isCustom;
147+ const isAnimated = imageData.isAnimated ?? false;
145148
146149 const thumbnail = $('#background_template .bg_example').clone();
147150
@@ -172,6 +175,7 @@ function createThumbnailElement(imageData) {
172175 thumbnail.attr('title', title);
173176 thumbnail.attr('bgfile', bg);
174177 thumbnail.attr('custom', String(isCustom));
178+ thumbnail.attr('animated', String(isAnimated));
175179 thumbnail.data('url', url);
176180 titleElement.text(friendlyTitle);
177181
@@ -216,6 +220,7 @@ export function loadBackgroundSettings(settings) {
216220 }
217221 background_settings.thumbnailColumns = columns;
218222 background_settings.sortOrder = backgroundSettings.sortOrder;
223+ background_settings.animation = backgroundSettings.animation;
219224 applyThumbnailColumns(background_settings.thumbnailColumns);
220225
221226 setBackground(backgroundSettings.name, backgroundSettings.url);
@@ -498,14 +503,13 @@ async function onDeleteBackgroundClick(e) {
498503 const url = bgToDelete.data('url');
499504 const isCustom = bgToDelete.attr('custom') === 'true';
500505 const deleteFromServerId = 'delete_bg_from_server';
501- const customInputs = [
506+ /** @type {import('./popup.js').CustomPopupInput[]} */
502- {
507+ const customInputs = [{
503508 type: 'checkbox',
504509 label: t`Also delete file from server`,
505510 id: deleteFromServerId,
506511 defaultState: true,
507- },
512+ }];
508- ];
509513 let deleteFromServer = false;
510514 const confirm = await Popup.show.confirm(t`Delete the background?`, null, {
511515 customInputs: isCustom ? customInputs : [],
@@ -522,7 +526,7 @@ async function onDeleteBackgroundClick(e) {
522526 if (!isCustom) {
523527 await delBackground(bg);
524528 // Remove from cache to prevent reappearing on sort change
525529 const cacheIndex = cachedSystemBackgrounds.indexOffindIndex(s => s.filename === bg);
526530 if (cacheIndex !== -1) {
527531 cachedSystemBackgrounds.splice(cacheIndex, 1);
528532 }
@@ -605,7 +609,7 @@ async function autoBackgroundCommand() {
605609
606610/**
607611 * Renders the system backgrounds gallery.
608612 * @param {Array<{filename: string[], isAnimated: boolean}>} [backgrounds] - Optional filtered list of backgrounds with metadata.
609613 */
610614function renderSystemBackgrounds(backgrounds) {
611615 const sourceList = backgrounds || [];
@@ -614,9 +618,11 @@ function renderSystemBackgrounds(backgrounds) {
614618
615619 if (sourceList.length === 0) return;
616620
617621 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 };
620626 const thumbnail = createThumbnailElement(imageData);
621627 container.append(thumbnail);
622628 });
@@ -638,7 +644,9 @@ function renderChatBackgrounds(backgrounds) {
638644
639645 const sortedList = sortBackgrounds(sourceList, true);
640646 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 };
642650 const thumbnail = createThumbnailElement(imageData);
643651 container.append(thumbnail);
644652 });
@@ -647,8 +655,6 @@ function renderChatBackgrounds(backgrounds) {
647655}
648656
649657export async function getBackgrounds() {
650- const metadataPromise = preloadImageMetadata();
651-
652658 const response = await fetch('/api/backgrounds/all', {
653659 method: 'POST',
654660 headers: getRequestHeaders(),
@@ -657,10 +663,8 @@ export async function getBackgrounds() {
657663 if (response.ok) {
658664 const { images, config } = await response.json();
659665 Object.assign(THUMBNAIL_CONFIG, config);
660-
661666 cachedSystemBackgrounds = images;
662-
667+ await preloadImageMetadata();
663- await metadataPromise;
664668
665669 renderSystemBackgrounds(images);
666670 highlightSelectedBackground();
@@ -716,7 +720,8 @@ function activateLazyLoader() {
716720 if (parentThumbnail) {
717721 const bg = parentThumbnail.getAttribute('bgfile');
718722 const isCustom = parentThumbnail.getAttribute('custom') === 'true';
719- resolveImageUrl(bg, isCustom)
723+ const isAnimated = parentThumbnail.getAttribute('animated') === 'true';
724+ resolveImageUrl(bg, isCustom, isAnimated)
720725 .then(url => { clipper.style.backgroundImage = url; })
721726 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
722727 }
@@ -745,16 +750,26 @@ function generateUrlParameter(bg, isCustom) {
745750 return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;
746751}
747752
753+function isAnimatedBackgroundExtension(fileName) {
754+ const fileExtension = fileName.split('.').pop().toLowerCase();
755+ return ANIMATED_BACKGROUND_EXTENSIONS.includes(fileExtension);
756+}
757+
748758/**
749759 * Resolves the image URL for the background.
750760 * @param {string} bg Background file name
751761 * @param {boolean} isCustom Is a custom background
762+ * @param {boolean|null} [isAnimated=null] Is the background animated (from metadata). If null, infers from extension.
752763 * @returns {Promise<string>} CSS URL of the background
753764 */
754765async 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
758773 ? await getThumbnailFromStorage(bg, isCustom)
759774 : isCustom
760775 ? bg
src/endpoints/backgrounds.js+38 -8
@@ -12,12 +12,33 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js
1212export const router = express.Router();
1313
1414router.post('/all', async function (request, response) {
15+ try {
1516 const images = getImages(request.user.directories.backgrounds);
1617 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+ }
1838});
1939
2040router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
41+ try {
2142 if (!request.body) return response.sendStatus(400);
2243
2344 if (request.body.bg !== sanitize(request.body.bg)) {
@@ -37,14 +58,19 @@ router.post('/delete', getFileNameValidationFunction('bg'), function (request, r
3758
3859 // Remove metadata for deleted image
3960 const relativePath = path.join('backgrounds', request.body.bg);
4061 await removeMetadata(request.user.directories.root, relativePath).catch(err => {
4162 console.warn('[Backgrounds] Failed to remove metadata:', err.message);
4263 });
4364
4465 return response.send('ok');
66+ } catch (err) {
67+ console.error(err);
68+ response.sendStatus(500);
69+ }
4570});
4671
4772router.post('/rename', async function (request, response) {
73+ try {
4874 if (!request.body) return response.sendStatus(400);
4975
5076 const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
@@ -67,27 +93,31 @@ router.post('/rename', function (request, response) {
6793 // Update metadata for renamed image
6894 const oldRelativePath = path.join('backgrounds', request.body.old_bg);
6995 const newRelativePath = path.join('backgrounds', request.body.new_bg);
7096 await renameMetadata(request.user.directories.root, oldRelativePath, newRelativePath).catch(err => {
7197 console.warn('[Backgrounds] Failed to rename metadata:', err.message);
7298 });
7399
74100 return response.send('ok');
101+ } catch (err) {
102+ console.error(err);
103+ response.sendStatus(500);
104+ }
75105});
76106
77107router.post('/upload', async function (request, response) {
108+ try {
78109 if (!request.body || !request.file) return response.sendStatus(400);
79110
80111 const img_path = path.join(request.file.destination, request.file.filename);
81112 const filename = sanitize(request.file.originalname);
82113
83- try {
84114 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
85115 fs.unlinkSync(img_path);
86116 invalidateThumbnail(request.user.directories, 'bg', filename);
87117
88118 // Generate metadata for the new image
89119 const relativePath = path.join('backgrounds', filename);
90120 await getOrGenerateMetadataBatch(request.user.directories.root, [relativePath], 'bg').catch(err => {
91121 console.warn('[Backgrounds] Failed to generate metadata for upload:', err.message);
92122 });
93123
src/endpoints/image-metadata.js+1 -46
@@ -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
@@ -319,51 +319,6 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
319319 return orphanedPaths;
320320}
321321
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-}
367322
368323export const router = express.Router();
369324
src/endpoints/thumbnails.js+1 -1
@@ -8,7 +8,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
88import { imageSize as sizeOf } from 'image-size';
99
1010import { getConfigValue, invalidateFirefoxCache } from '../util.js';
1111import { getThumbnailResolution, isAnimatedWebP, isAnimatedApng, thumbnailDimensions as dimensions, isAnimatedApng } from './image-metadata.js';
1212import { ResizeStrategy } from '@jimp/plugin-resize';
1313
1414export const publicRouter = express.Router();
src/server-main.js+0 -4
@@ -69,7 +69,6 @@ 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';
7372
7473// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
7574// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -300,9 +299,6 @@ async function preSetupTasks() {
300299 await settingsInit();
301300 await statsInit();
302301
303- // Initialize image metadata
304- await initializeAllUserMetadata(directories);
305-
306302 const pluginsDirectory = path.join(serverDirectory, 'plugins');
307303 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
308304 const consoleTitle = process.title;