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, +127 -131Ignore whitespace
public/scripts/backgrounds.js+41 -26
@@ -41,6 +41,8 @@ const THUMBNAIL_CONFIG = {
41 height: 90,41 height: 90,
42};42};
4343
44const ANIMATED_BACKGROUND_EXTENSIONS = ['mp4', 'webp', 'gif', 'apng'];
45
44/**46/**
45 * Cache for image metadata.47 * Cache for image metadata.
46 * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}48 * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
@@ -88,7 +90,7 @@ let lazyLoadObserver = null;
88/**90/**
89 * Cache for the current list of system background filenames.91 * Cache for the current list of system background filenames.
90 * Used to re-sort backgrounds without refetching from the server.92 * Used to re-sort backgrounds without refetching from the server.
91 * @type {string[]}93 * @type {Array<{filename: string, isAnimated: boolean}>}
92 */94 */
93let cachedSystemBackgrounds = [];95let cachedSystemBackgrounds = [];
9496
@@ -136,12 +138,13 @@ function sortBackgrounds(backgrounds, isCustom = false) {
136138
137/**139/**
138 * Creates a single thumbnail DOM element. The CSS now handles all sizing.140 * Creates a single thumbnail DOM element. The CSS now handles all sizing.
139 * @param {object} imageData - Data for the image (filename, isCustom).141 * @param {object} imageData - Data for the image (filename, isCustom, isAnimated).
140 * @returns {HTMLElement} The created thumbnail element.142 * @returns {HTMLElement} The created thumbnail element.
141 */143 */
142function createThumbnailElement(imageData) {144function createThumbnailElement(imageData) {
143 const bg = imageData.filename;145 const bg = imageData.filename;
144 const isCustom = imageData.isCustom;146 const isCustom = imageData.isCustom;
147 const isAnimated = imageData.isAnimated ?? false;
145148
146 const thumbnail = $('#background_template .bg_example').clone();149 const thumbnail = $('#background_template .bg_example').clone();
147150
@@ -172,6 +175,7 @@ function createThumbnailElement(imageData) {
172 thumbnail.attr('title', title);175 thumbnail.attr('title', title);
173 thumbnail.attr('bgfile', bg);176 thumbnail.attr('bgfile', bg);
174 thumbnail.attr('custom', String(isCustom));177 thumbnail.attr('custom', String(isCustom));
178 thumbnail.attr('animated', String(isAnimated));
175 thumbnail.data('url', url);179 thumbnail.data('url', url);
176 titleElement.text(friendlyTitle);180 titleElement.text(friendlyTitle);
177181
@@ -216,6 +220,7 @@ export function loadBackgroundSettings(settings) {
216 }220 }
217 background_settings.thumbnailColumns = columns;221 background_settings.thumbnailColumns = columns;
218 background_settings.sortOrder = backgroundSettings.sortOrder;222 background_settings.sortOrder = backgroundSettings.sortOrder;
223 background_settings.animation = backgroundSettings.animation;
219 applyThumbnailColumns(background_settings.thumbnailColumns);224 applyThumbnailColumns(background_settings.thumbnailColumns);
220225
221 setBackground(backgroundSettings.name, backgroundSettings.url);226 setBackground(backgroundSettings.name, backgroundSettings.url);
@@ -498,14 +503,13 @@ async function onDeleteBackgroundClick(e) {
498 const url = bgToDelete.data('url');503 const url = bgToDelete.data('url');
499 const isCustom = bgToDelete.attr('custom') === 'true';504 const isCustom = bgToDelete.attr('custom') === 'true';
500 const deleteFromServerId = 'delete_bg_from_server';505 const deleteFromServerId = 'delete_bg_from_server';
501 const customInputs = [506 /** @type {import('./popup.js').CustomPopupInput[]} */
502 {507 const customInputs = [{
503 type: 'checkbox',508 type: 'checkbox',
504 label: t`Also delete file from server`,509 label: t`Also delete file from server`,
505 id: deleteFromServerId,510 id: deleteFromServerId,
506 defaultState: true,511 defaultState: true,
507 },512 }];
508 ];
509 let deleteFromServer = false;513 let deleteFromServer = false;
510 const confirm = await Popup.show.confirm(t`Delete the background?`, null, {514 const confirm = await Popup.show.confirm(t`Delete the background?`, null, {
511 customInputs: isCustom ? customInputs : [],515 customInputs: isCustom ? customInputs : [],
@@ -522,7 +526,7 @@ async function onDeleteBackgroundClick(e) {
522 if (!isCustom) {526 if (!isCustom) {
523 await delBackground(bg);527 await delBackground(bg);
524 // Remove from cache to prevent reappearing on sort change528 // Remove from cache to prevent reappearing on sort change
525 const cacheIndex = cachedSystemBackgrounds.indexOf(bg);529 const cacheIndex = cachedSystemBackgrounds.findIndex(s => s.filename === bg);
526 if (cacheIndex !== -1) {530 if (cacheIndex !== -1) {
527 cachedSystemBackgrounds.splice(cacheIndex, 1);531 cachedSystemBackgrounds.splice(cacheIndex, 1);
528 }532 }
@@ -605,7 +609,7 @@ async function autoBackgroundCommand() {
605609
606/**610/**
607 * Renders the system backgrounds gallery.611 * Renders the system backgrounds gallery.
608 * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.612 * @param {Array<{filename: string, isAnimated: boolean}>} [backgrounds] - Optional filtered list of backgrounds with metadata.
609 */613 */
610function renderSystemBackgrounds(backgrounds) {614function renderSystemBackgrounds(backgrounds) {
611 const sourceList = backgrounds || [];615 const sourceList = backgrounds || [];
@@ -614,9 +618,11 @@ function renderSystemBackgrounds(backgrounds) {
614618
615 if (sourceList.length === 0) return;619 if (sourceList.length === 0) return;
616620
617 const sortedList = sortBackgrounds(sourceList, false);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 const thumbnail = createThumbnailElement(imageData);626 const thumbnail = createThumbnailElement(imageData);
621 container.append(thumbnail);627 container.append(thumbnail);
622 });628 });
@@ -638,7 +644,9 @@ function renderChatBackgrounds(backgrounds) {
638644
639 const sortedList = sortBackgrounds(sourceList, true);645 const sortedList = sortBackgrounds(sourceList, true);
640 sortedList.forEach(bg => {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 const thumbnail = createThumbnailElement(imageData);650 const thumbnail = createThumbnailElement(imageData);
643 container.append(thumbnail);651 container.append(thumbnail);
644 });652 });
@@ -647,8 +655,6 @@ function renderChatBackgrounds(backgrounds) {
647}655}
648656
649export async function getBackgrounds() {657export async function getBackgrounds() {
650 const metadataPromise = preloadImageMetadata();
651
652 const response = await fetch('/api/backgrounds/all', {658 const response = await fetch('/api/backgrounds/all', {
653 method: 'POST',659 method: 'POST',
654 headers: getRequestHeaders(),660 headers: getRequestHeaders(),
@@ -657,10 +663,8 @@ export async function getBackgrounds() {
657 if (response.ok) {663 if (response.ok) {
658 const { images, config } = await response.json();664 const { images, config } = await response.json();
659 Object.assign(THUMBNAIL_CONFIG, config);665 Object.assign(THUMBNAIL_CONFIG, config);
660
661 cachedSystemBackgrounds = images;666 cachedSystemBackgrounds = images;
662667 await preloadImageMetadata();
663 await metadataPromise;
664668
665 renderSystemBackgrounds(images);669 renderSystemBackgrounds(images);
666 highlightSelectedBackground();670 highlightSelectedBackground();
@@ -716,7 +720,8 @@ function activateLazyLoader() {
716 if (parentThumbnail) {720 if (parentThumbnail) {
717 const bg = parentThumbnail.getAttribute('bgfile');721 const bg = parentThumbnail.getAttribute('bgfile');
718 const isCustom = parentThumbnail.getAttribute('custom') === 'true';722 const isCustom = parentThumbnail.getAttribute('custom') === 'true';
719 resolveImageUrl(bg, isCustom)723 const isAnimated = parentThumbnail.getAttribute('animated') === 'true';
724 resolveImageUrl(bg, isCustom, isAnimated)
720 .then(url => { clipper.style.backgroundImage = url; })725 .then(url => { clipper.style.backgroundImage = url; })
721 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });726 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
722 }727 }
@@ -745,16 +750,26 @@ function generateUrlParameter(bg, isCustom) {
745 return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;750 return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;
746}751}
747752
753function isAnimatedBackgroundExtension(fileName) {
754 const fileExtension = fileName.split('.').pop().toLowerCase();
755 return ANIMATED_BACKGROUND_EXTENSIONS.includes(fileExtension);
756}
757
748/**758/**
749 * Resolves the image URL for the background.759 * Resolves the image URL for the background.
750 * @param {string} bg Background file name760 * @param {string} bg Background file name
751 * @param {boolean} isCustom Is a custom background761 * @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 * @returns {Promise<string>} CSS URL of the background763 * @returns {Promise<string>} CSS URL of the background
753 */764 */
754async function resolveImageUrl(bg, isCustom) {765async 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.animation768 if (animated === null) {
769 animated = isAnimatedBackgroundExtension(bg);
770 }
771
772 const thumbnailUrl = animated && !background_settings.animation
758 ? await getThumbnailFromStorage(bg, isCustom)773 ? await getThumbnailFromStorage(bg, isCustom)
759 : isCustom774 : isCustom
760 ? bg775 ? bg
src/endpoints/backgrounds.js+84 -54
@@ -12,82 +12,112 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js
12export const router = express.Router();12export const router = express.Router();
1313
14router.post('/all', async function (request, response) {14router.post('/all', async function (request, response) {
15 const images = getImages(request.user.directories.backgrounds);15 try {
16 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };16 const images = getImages(request.user.directories.backgrounds);
17 response.json({ images, config });17 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };
18});18
1919 // Get metadata for all images to provide isAnimated flag to client
20router.post('/delete', getFileNameValidationFunction('bg'), function (request, response) {20 const relativePaths = images.map(img => path.join('backgrounds', img));
21 if (!request.body) return response.sendStatus(400);21 const { results: metadataMap } = await getOrGenerateMetadataBatch(request.user.directories.root, relativePaths, 'bg');
2222
23 if (request.body.bg !== sanitize(request.body.bg)) {23 // Build response with metadata for each image
24 console.error('Malicious bg name prevented');24 const imagesWithMetadata = images.map(img => {
25 return response.sendStatus(403);25 const relativePath = path.join('backgrounds', img);
26 }26 const metadata = metadataMap[relativePath];
2727 return {
28 const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));28 filename: img,
29 isAnimated: metadata?.isAnimated ?? false,
30 };
31 });
2932
30 if (!fs.existsSync(fileName)) {33 response.json({ images: imagesWithMetadata, config });
31 console.error('BG file not found');34 } catch (error) {
32 return response.sendStatus(400);35 console.error('[Backgrounds] Error fetching backgrounds:', error);
36 response.status(500).json({ error: 'Failed to fetch backgrounds' });
33 }37 }
38});
3439
35 fs.unlinkSync(fileName);40router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
36 invalidateThumbnail(request.user.directories, 'bg', request.body.bg);41 try {
42 if (!request.body) return response.sendStatus(400);
3743
38 // Remove metadata for deleted image44 if (request.body.bg !== sanitize(request.body.bg)) {
39 const relativePath = path.join('backgrounds', request.body.bg);45 console.error('Malicious bg name prevented');
40 removeMetadata(request.user.directories.root, relativePath).catch(err => {46 return response.sendStatus(403);
41 console.warn('[Backgrounds] Failed to remove metadata:', err.message);47 }
42 });
4348
44 return response.send('ok');49 const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
45});
4650
47router.post('/rename', function (request, response) {51 if (!fs.existsSync(fileName)) {
48 if (!request.body) return response.sendStatus(400);52 console.error('BG file not found');
53 return response.sendStatus(400);
54 }
4955
50 const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));56 fs.unlinkSync(fileName);
51 const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));57 invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
5258
53 if (!fs.existsSync(oldFileName)) {59 // Remove metadata for deleted image
54 console.error('BG file not found');60 const relativePath = path.join('backgrounds', request.body.bg);
55 return response.sendStatus(400);61 await removeMetadata(request.user.directories.root, relativePath).catch(err => {
56 }62 console.warn('[Backgrounds] Failed to remove metadata:', err.message);
63 });
5764
58 if (fs.existsSync(newFileName)) {65 return response.send('ok');
59 console.error('New BG file already exists');66 } catch (err) {
60 return response.sendStatus(400);67 console.error(err);
68 response.sendStatus(500);
61 }69 }
70});
6271
63 fs.copyFileSync(oldFileName, newFileName);72router.post('/rename', async function (request, response) {
64 fs.unlinkSync(oldFileName);73 try {
65 invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);74 if (!request.body) return response.sendStatus(400);
6675
67 // Update metadata for renamed image76 const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
68 const oldRelativePath = path.join('backgrounds', request.body.old_bg);77 const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
69 const newRelativePath = path.join('backgrounds', request.body.new_bg);78
70 renameMetadata(request.user.directories.root, oldRelativePath, newRelativePath).catch(err => {79 if (!fs.existsSync(oldFileName)) {
71 console.warn('[Backgrounds] Failed to rename metadata:', err.message);80 console.error('BG file not found');
72 });81 return response.sendStatus(400);
82 }
83
84 if (fs.existsSync(newFileName)) {
85 console.error('New BG file already exists');
86 return response.sendStatus(400);
87 }
88
89 fs.copyFileSync(oldFileName, newFileName);
90 fs.unlinkSync(oldFileName);
91 invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
92
93 // Update metadata for renamed image
94 const oldRelativePath = path.join('backgrounds', request.body.old_bg);
95 const newRelativePath = path.join('backgrounds', request.body.new_bg);
96 await renameMetadata(request.user.directories.root, oldRelativePath, newRelativePath).catch(err => {
97 console.warn('[Backgrounds] Failed to rename metadata:', err.message);
98 });
7399
74 return response.send('ok');100 return response.send('ok');
101 } catch (err) {
102 console.error(err);
103 response.sendStatus(500);
104 }
75});105});
76106
77router.post('/upload', function (request, response) {107router.post('/upload', async function (request, response) {
78 if (!request.body || !request.file) return response.sendStatus(400);108 try {
109 if (!request.body || !request.file) return response.sendStatus(400);
79110
80 const img_path = path.join(request.file.destination, request.file.filename);111 const img_path = path.join(request.file.destination, request.file.filename);
81 const filename = sanitize(request.file.originalname);112 const filename = sanitize(request.file.originalname);
82113
83 try {
84 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));114 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
85 fs.unlinkSync(img_path);115 fs.unlinkSync(img_path);
86 invalidateThumbnail(request.user.directories, 'bg', filename);116 invalidateThumbnail(request.user.directories, 'bg', filename);
87117
88 // Generate metadata for the new image118 // Generate metadata for the new image
89 const relativePath = path.join('backgrounds', filename);119 const relativePath = path.join('backgrounds', filename);
90 getOrGenerateMetadataBatch(request.user.directories.root, [relativePath], 'bg').catch(err => {120 await getOrGenerateMetadataBatch(request.user.directories.root, [relativePath], 'bg').catch(err => {
91 console.warn('[Backgrounds] Failed to generate metadata for upload:', err.message);121 console.warn('[Backgrounds] Failed to generate metadata for upload:', err.message);
92 });122 });
93123
src/endpoints/image-metadata.js+1 -46
@@ -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, getImages, isPathUnderParent } from '../util.js';13import { getConfigValue, isPathUnderParent } from '../util.js';
1414
15export const METADATA_FILE = 'image-metadata.json';15export const METADATA_FILE = 'image-metadata.json';
1616
@@ -319,51 +319,6 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
319 return orphanedPaths;319 return orphanedPaths;
320}320}
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 */
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}
367322
368export const router = express.Router();323export const router = express.Router();
369324
src/endpoints/thumbnails.js+1 -1
@@ -8,7 +8,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
8import { imageSize as sizeOf } from 'image-size';8import { imageSize as sizeOf } from 'image-size';
99
10import { getConfigValue, invalidateFirefoxCache } from '../util.js';10import { getConfigValue, invalidateFirefoxCache } from '../util.js';
11import { getThumbnailResolution, isAnimatedWebP, thumbnailDimensions as dimensions, isAnimatedApng } from './image-metadata.js';11import { getThumbnailResolution, isAnimatedWebP, isAnimatedApng, thumbnailDimensions as dimensions } from './image-metadata.js';
12import { ResizeStrategy } from '@jimp/plugin-resize';12import { ResizeStrategy } from '@jimp/plugin-resize';
1313
14export const publicRouter = express.Router();14export const publicRouter = express.Router();
src/server-main.js+0 -4
@@ -69,7 +69,6 @@ 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';
7372
74// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.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// https://github.com/nodejs/node/issues/47822#issuecomment-156470887074// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -300,9 +299,6 @@ async function preSetupTasks() {
300 await settingsInit();299 await settingsInit();
301 await statsInit();300 await statsInit();
302301
303 // Initialize image metadata
304 await initializeAllUserMetadata(directories);
305
306 const pluginsDirectory = path.join(serverDirectory, 'plugins');302 const pluginsDirectory = path.join(serverDirectory, 'plugins');
307 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);303 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
308 const consoleTitle = process.title;304 const consoleTitle = process.title;