Implement persona thumbnails (#4210)
* Implement persona thumbnails * Dear Firefox, fix your overzealous image cache * Add cache busting for avatar uploads when overwriting existing files
This commit is contained in:
+1
-1
@@ -139,7 +139,7 @@ thumbnails:
|
||||
# JPG thumbnail quality (0-100)
|
||||
quality: 95
|
||||
# Maximum thumbnail dimensions per type [width, height]
|
||||
dimensions: { 'bg': [160, 90], 'avatar': [96, 144] }
|
||||
dimensions: { 'bg': [160, 90], 'avatar': [96, 144], 'persona': [96, 144] }
|
||||
|
||||
# PERFORMANCE-RELATED CONFIGURATION
|
||||
performance:
|
||||
|
||||
+24
-9
@@ -2598,7 +2598,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
|
||||
mes.swipes = [mes.mes];
|
||||
}
|
||||
|
||||
let avatarImg = getUserAvatar(user_avatar);
|
||||
let avatarImg = getThumbnailUrl('persona', user_avatar);
|
||||
const isSystem = mes.is_system;
|
||||
const title = mes.title;
|
||||
generatedPromptCache = '';
|
||||
@@ -5575,7 +5575,7 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
|
||||
|
||||
// Lock user avatar to a persona.
|
||||
if (avatar in power_user.personas) {
|
||||
message.force_avatar = getUserAvatar(avatar);
|
||||
message.force_avatar = getThumbnailUrl('persona', avatar);
|
||||
}
|
||||
|
||||
if (messageBias) {
|
||||
@@ -7204,7 +7204,7 @@ async function read_avatar_load(input) {
|
||||
await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
|
||||
|
||||
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
|
||||
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url')), {
|
||||
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {
|
||||
method: 'GET',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
@@ -7233,8 +7233,15 @@ async function read_avatar_load(input) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getThumbnailUrl(type, file) {
|
||||
return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}`;
|
||||
/**
|
||||
* Gets the URL for a thumbnail of a specific type and file.
|
||||
* @param {import('../src/endpoints/thumbnails.js').ThumbnailType} type The type of the thumbnail to get
|
||||
* @param {string} file The file name or path for which to get the thumbnail URL
|
||||
* @param {boolean} [t=false] Whether to add a cache-busting timestamp to the URL
|
||||
* @returns {string} The URL for the thumbnail
|
||||
*/
|
||||
export function getThumbnailUrl(type, file, t = false) {
|
||||
return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}${t ? `&t=${Date.now()}` : ''}`;
|
||||
}
|
||||
|
||||
export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, interactable = false, highlightFavs = true } = {}) {
|
||||
@@ -7274,7 +7281,7 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
|
||||
}
|
||||
else if (entity.type === 'persona') {
|
||||
avatarTemplate.attr({ 'data-pid': id, 'data-chid': null });
|
||||
avatarTemplate.find('img').attr('src', getUserAvatar(entity.item.avatar));
|
||||
avatarTemplate.find('img').attr('src', getThumbnailUrl('persona', entity.item.avatar));
|
||||
avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`);
|
||||
}
|
||||
|
||||
@@ -12147,9 +12154,17 @@ jQuery(async function () {
|
||||
$('body').append(newElement);
|
||||
newElement.fadeIn(animation_duration);
|
||||
const zoomedAvatarImgElement = $(`.zoomed_avatar[forChar="${charname}"] img`);
|
||||
if (messageElement.attr('is_user') == 'true' || (messageElement.attr('is_system') == 'true' && !isValidCharacter)) { //handle user and system avatars
|
||||
zoomedAvatarImgElement.attr('src', thumbURL);
|
||||
zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL);
|
||||
if (messageElement.attr('is_user') == 'true' || (messageElement.attr('is_system') == 'true' && !isValidCharacter)) {
|
||||
//handle user and system avatars
|
||||
const isValidPersona = decodeURIComponent(targetAvatarImg) in power_user.personas;
|
||||
if (isValidPersona) {
|
||||
const personaSrc = getUserAvatar(targetAvatarImg);
|
||||
zoomedAvatarImgElement.attr('src', personaSrc);
|
||||
zoomedAvatarImgElement.attr('data-izoomify-url', personaSrc);
|
||||
} else {
|
||||
zoomedAvatarImgElement.attr('src', thumbURL);
|
||||
zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL);
|
||||
}
|
||||
} else if (messageElement.attr('is_user') == 'false') { //handle char avatars
|
||||
zoomedAvatarImgElement.attr('src', avatarSrc);
|
||||
zoomedAvatarImgElement.attr('data-izoomify-url', avatarSrc);
|
||||
|
||||
@@ -45,6 +45,10 @@ class DataMaidDialog {
|
||||
name: t`Background Thumbnails`,
|
||||
description: t`Thumbnails for missing or deleted backgrounds.`,
|
||||
},
|
||||
personaThumbnails: {
|
||||
name: t`Persona Thumbnails`,
|
||||
description: t`Thumbnails for missing or deleted personas.`,
|
||||
},
|
||||
chatBackups: {
|
||||
name: t`Chat Backups`,
|
||||
description: t`Automatically generated chat backups.`,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from '
|
||||
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { isFirefox } from './browser-fixes.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} PersonaConnection A connection between a character and a character or group entity
|
||||
@@ -124,7 +125,7 @@ function reloadUserAvatar(force = false) {
|
||||
}
|
||||
|
||||
if ($(this).attr('is_user') == 'true' && $(this).attr('force_avatar') == 'false') {
|
||||
avatarImg.attr('src', getUserAvatar(user_avatar));
|
||||
avatarImg.attr('src', getThumbnailUrl('persona', user_avatar));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -179,7 +180,6 @@ function verifyPersonaSearchSortRule() {
|
||||
* @returns {JQuery<HTMLElement>} Avatar block
|
||||
*/
|
||||
function getUserAvatarBlock(avatarId) {
|
||||
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
const template = $('#user_avatar_template .avatar-container').clone();
|
||||
const personaName = power_user.personas[avatarId];
|
||||
const personaDescription = power_user.persona_descriptions[avatarId]?.description;
|
||||
@@ -189,10 +189,7 @@ function getUserAvatarBlock(avatarId) {
|
||||
template.attr('data-avatar-id', avatarId);
|
||||
template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId);
|
||||
template.toggleClass('default_persona', avatarId === power_user.default_persona);
|
||||
let avatarUrl = getUserAvatar(avatarId);
|
||||
if (isFirefox) {
|
||||
avatarUrl += '?t=' + Date.now();
|
||||
}
|
||||
const avatarUrl = getThumbnailUrl('persona', avatarId, isFirefox());
|
||||
template.find('img').attr('src', avatarUrl);
|
||||
|
||||
// Make sure description block has at least three rows. Otherwise height looks inconsistent. I don't have a better idea for this.
|
||||
@@ -386,6 +383,7 @@ async function changeUserAvatar(e) {
|
||||
const name = formData.get('overwrite_name');
|
||||
if (name) {
|
||||
await fetch(getUserAvatar(String(name)), { cache: 'no-cache' });
|
||||
await fetch(getThumbnailUrl('persona', String(name)), { cache: 'no-cache' });
|
||||
reloadUserAvatar(true);
|
||||
}
|
||||
|
||||
@@ -1657,7 +1655,7 @@ async function syncUserNameToPersona() {
|
||||
for (const mes of chat) {
|
||||
if (mes.is_user) {
|
||||
mes.name = name1;
|
||||
mes.force_avatar = getUserAvatar(user_avatar);
|
||||
mes.force_avatar = getThumbnailUrl('persona', user_avatar);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const USER_DIRECTORY_TEMPLATE = Object.freeze({
|
||||
thumbnails: 'thumbnails',
|
||||
thumbnailsBg: 'thumbnails/bg',
|
||||
thumbnailsAvatar: 'thumbnails/avatar',
|
||||
thumbnailsPersona: 'thumbnails/persona',
|
||||
worlds: 'worlds',
|
||||
user: 'user',
|
||||
avatars: 'User Avatars',
|
||||
|
||||
@@ -9,12 +9,13 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
||||
import { getImages, tryParse } from '../util.js';
|
||||
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
||||
import { applyAvatarCropResize } from './characters.js';
|
||||
import { invalidateThumbnail } from './thumbnails.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/get', function (request, response) {
|
||||
var images = getImages(request.user.directories.avatars);
|
||||
response.send(JSON.stringify(images));
|
||||
const images = getImages(request.user.directories.avatars);
|
||||
response.send(images);
|
||||
});
|
||||
|
||||
router.post('/delete', getFileNameValidationFunction('avatar'), function (request, response) {
|
||||
@@ -29,6 +30,7 @@ router.post('/delete', getFileNameValidationFunction('avatar'), function (reques
|
||||
|
||||
if (fs.existsSync(fileName)) {
|
||||
fs.unlinkSync(fileName);
|
||||
invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.avatar));
|
||||
return response.send({ result: 'ok' });
|
||||
}
|
||||
|
||||
@@ -44,6 +46,12 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r
|
||||
const rawImg = await Jimp.read(pathToUpload);
|
||||
const image = await applyAvatarCropResize(rawImg, crop);
|
||||
|
||||
// Remove previous thumbnail and bust cache if overwriting
|
||||
if (request.body.overwrite_name) {
|
||||
invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name));
|
||||
response.setHeader('Clear-Site-Data', '"cache"');
|
||||
}
|
||||
|
||||
const filename = request.body.overwrite_name || `${Date.now()}.png`;
|
||||
const pathToNewFile = path.join(request.user.directories.avatars, filename);
|
||||
writeFileAtomicSync(pathToNewFile, image);
|
||||
|
||||
@@ -18,6 +18,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
|
||||
* @property {string[]} groupChats - List of loose group chats
|
||||
* @property {string[]} avatarThumbnails - List of loose avatar thumbnails
|
||||
* @property {string[]} backgroundThumbnails - List of loose background thumbnails
|
||||
* @property {string[]} personaThumbnails - List of loose persona thumbnails
|
||||
* @property {string[]} chatBackups - List of chat backups
|
||||
* @property {string[]} settingsBackups - List of settings backups
|
||||
*/
|
||||
@@ -39,6 +40,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
|
||||
* @property {DataMaidSanitizedRecord[]} groupChats - List of sanitized loose group chats
|
||||
* @property {DataMaidSanitizedRecord[]} avatarThumbnails - List of sanitized loose avatar thumbnails
|
||||
* @property {DataMaidSanitizedRecord[]} backgroundThumbnails - List of sanitized loose background thumbnails
|
||||
* @property {DataMaidSanitizedRecord[]} personaThumbnails - List of sanitized loose persona thumbnails
|
||||
* @property {DataMaidSanitizedRecord[]} chatBackups - List of sanitized chat backups
|
||||
* @property {DataMaidSanitizedRecord[]} settingsBackups - List of sanitized settings backups
|
||||
*/
|
||||
@@ -106,6 +108,7 @@ export class DataMaidService {
|
||||
groupChats: await this.#collectGroupChats(),
|
||||
avatarThumbnails: await this.#collectAvatarThumbnails(),
|
||||
backgroundThumbnails: await this.#collectBackgroundThumbnails(),
|
||||
personaThumbnails: await this.#collectPersonaThumbnails(),
|
||||
chatBackups: await this.#collectChatBackups(),
|
||||
settingsBackups: await this.#collectSettingsBackups(),
|
||||
};
|
||||
@@ -145,6 +148,7 @@ export class DataMaidService {
|
||||
groupChats: await Promise.all(report.groupChats.map(i => this.#sanitizeRecord(i, false))),
|
||||
avatarThumbnails: await Promise.all(report.avatarThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
||||
backgroundThumbnails: await Promise.all(report.backgroundThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
||||
personaThumbnails: await Promise.all(report.personaThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
||||
chatBackups: await Promise.all(report.chatBackups.map(i => this.#sanitizeRecord(i, false))),
|
||||
settingsBackups: await Promise.all(report.settingsBackups.map(i => this.#sanitizeRecord(i, false))),
|
||||
};
|
||||
@@ -415,6 +419,34 @@ export class DataMaidService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects loose persona thumbnails from the provided directories.
|
||||
* @returns {Promise<string[]>} List of paths to loose persona thumbnails
|
||||
*/
|
||||
async #collectPersonaThumbnails() {
|
||||
const result = [];
|
||||
|
||||
try {
|
||||
const knownPersonas = new Set();
|
||||
const personas = await fs.promises.readdir(this.directories.avatars, { withFileTypes: true });
|
||||
for (const file of personas) {
|
||||
if (file.isFile()) {
|
||||
knownPersonas.add(file.name);
|
||||
}
|
||||
}
|
||||
const personaThumbnails = await fs.promises.readdir(this.directories.thumbnailsPersona, { withFileTypes: true });
|
||||
for (const file of personaThumbnails) {
|
||||
if (file.isFile() && !knownPersonas.has(file.name)) {
|
||||
result.push(path.join(this.directories.thumbnailsPersona, file.name));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Data Maid] Error collecting persona thumbnails:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects chat backups from the provided directories.
|
||||
* @returns {Promise<string[]>} List of paths to chat backups
|
||||
|
||||
@@ -14,16 +14,21 @@ const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'
|
||||
const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
|
||||
const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';
|
||||
|
||||
/**
|
||||
* @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
|
||||
*/
|
||||
|
||||
/** @type {Record<string, number[]>} */
|
||||
export const dimensions = {
|
||||
'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
|
||||
'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
|
||||
'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a path to thumbnail folder based on the type.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {'bg' | 'avatar'} type Thumbnail type
|
||||
* @param {ThumbnailType} type Thumbnail type
|
||||
* @returns {string} Path to the thumbnails folder
|
||||
*/
|
||||
function getThumbnailFolder(directories, type) {
|
||||
@@ -36,6 +41,9 @@ function getThumbnailFolder(directories, type) {
|
||||
case 'avatar':
|
||||
thumbnailFolder = directories.thumbnailsAvatar;
|
||||
break;
|
||||
case 'persona':
|
||||
thumbnailFolder = directories.thumbnailsPersona;
|
||||
break;
|
||||
}
|
||||
|
||||
return thumbnailFolder;
|
||||
@@ -44,7 +52,7 @@ function getThumbnailFolder(directories, type) {
|
||||
/**
|
||||
* Gets a path to the original images folder based on the type.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {'bg' | 'avatar'} type Thumbnail type
|
||||
* @param {ThumbnailType} type Thumbnail type
|
||||
* @returns {string} Path to the original images folder
|
||||
*/
|
||||
function getOriginalFolder(directories, type) {
|
||||
@@ -57,6 +65,9 @@ function getOriginalFolder(directories, type) {
|
||||
case 'avatar':
|
||||
originalFolder = directories.characters;
|
||||
break;
|
||||
case 'persona':
|
||||
originalFolder = directories.avatars;
|
||||
break;
|
||||
}
|
||||
|
||||
return originalFolder;
|
||||
@@ -65,7 +76,7 @@ function getOriginalFolder(directories, type) {
|
||||
/**
|
||||
* Removes the generated thumbnail from the disk.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {'bg' | 'avatar'} type Type of the thumbnail
|
||||
* @param {ThumbnailType} type Type of the thumbnail
|
||||
* @param {string} file Name of the file
|
||||
*/
|
||||
export function invalidateThumbnail(directories, type, file) {
|
||||
@@ -82,7 +93,7 @@ export function invalidateThumbnail(directories, type, file) {
|
||||
/**
|
||||
* Generates a thumbnail for the given file.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {'bg' | 'avatar'} type Type of the thumbnail
|
||||
* @param {ThumbnailType} type Type of the thumbnail
|
||||
* @param {string} file Name of the file
|
||||
* @returns
|
||||
*/
|
||||
@@ -188,7 +199,7 @@ router.get('/', async function (request, response) {
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
|
||||
if (!(type == 'bg' || type == 'avatar')) {
|
||||
if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const STORAGE_KEYS = {
|
||||
* @property {string} thumbnails - The directory where the thumbnails are stored
|
||||
* @property {string} thumbnailsBg - The directory where the background thumbnails are stored
|
||||
* @property {string} thumbnailsAvatar - The directory where the avatar thumbnails are stored
|
||||
* @property {string} thumbnailsPersona - The directory where the persona thumbnails are stored
|
||||
* @property {string} worlds - The directory where the WI are stored
|
||||
* @property {string} user - The directory where the user's public data is stored
|
||||
* @property {string} avatars - The directory where the avatars are stored
|
||||
|
||||
Reference in New Issue
Block a user