SillyTavern/public/scripts/extensions/expressions/index.js

1506 lines
52 KiB
JavaScript
Raw Normal View History

import { callPopup, eventSource, event_types, getRequestHeaders, saveSettingsDebounced, this_chid } from "../../../script.js";
2023-07-20 19:32:15 +02:00
import { dragElement, isMobile } from "../../RossAscends-mods.js";
2023-08-25 22:48:59 +02:00
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplate } from "../../extensions.js";
2023-07-20 19:32:15 +02:00
import { loadMovingUIState, power_user } from "../../power-user.js";
import { registerSlashCommand } from "../../slash-commands.js";
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence } from "../../utils.js";
2023-07-20 19:32:15 +02:00
export { MODULE_NAME };
const MODULE_NAME = 'expressions';
const UPDATE_INTERVAL = 2000;
const STREAMING_UPDATE_INTERVAL = 6000;
2023-07-20 19:32:15 +02:00
const FALLBACK_EXPRESSION = 'joy';
const DEFAULT_EXPRESSIONS = [
2023-08-10 23:52:14 +02:00
"talkinghead",
2023-07-20 19:32:15 +02:00
"admiration",
"amusement",
"anger",
"annoyance",
"approval",
"caring",
"confusion",
"curiosity",
"desire",
"disappointment",
"disapproval",
"disgust",
"embarrassment",
"excitement",
"fear",
"gratitude",
"grief",
"joy",
"love",
"nervousness",
"optimism",
"pride",
"realization",
"relief",
"remorse",
"sadness",
"surprise",
"neutral"
];
let expressionsList = null;
let lastCharacter = undefined;
let lastMessage = null;
let spriteCache = {};
let inApiCall = false;
let lastServerResponseTime = 0;
2023-07-20 19:32:15 +02:00
function isVisualNovelMode() {
return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
}
async function forceUpdateVisualNovelMode() {
if (isVisualNovelMode()) {
await updateVisualNovelMode();
}
}
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, 100);
async function updateVisualNovelMode(name, expression) {
const container = $('#visual-novel-wrapper');
await visualNovelRemoveInactive(container);
const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);
// calculate layer indices based on recent messages
await visualNovelUpdateLayers(container);
await Promise.allSettled(setSpritePromises);
// update again based on new sprites
if (setSpritePromises.length > 0) {
await visualNovelUpdateLayers(container);
}
}
async function visualNovelRemoveInactive(container) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const removeInactiveCharactersPromises = [];
// remove inactive characters after 1 second
container.find('.expression-holder').each((_, current) => {
const promise = new Promise(resolve => {
const element = $(current);
const avatar = element.data('avatar');
if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) {
element.fadeOut(250, () => {
element.remove();
resolve();
});
} else {
resolve();
}
});
removeInactiveCharactersPromises.push(promise);
});
await Promise.allSettled(removeInactiveCharactersPromises);
}
async function visualNovelSetCharacterSprites(container, name, expression) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const labels = await getExpressionsList();
const createCharacterPromises = [];
const setSpritePromises = [];
for (const avatar of group.members) {
const isDisabled = group.disabled_members.includes(avatar);
// skip disabled characters
if (isDisabled) {
continue;
}
const character = context.characters.find(x => x.avatar == avatar);
if (!character) {
continue;
}
const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
2023-07-20 19:32:15 +02:00
// download images if not downloaded yet
if (spriteCache[spriteFolderName] === undefined) {
spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);
}
const sprites = spriteCache[spriteFolderName];
const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
const defaultSpritePath = sprites.find(x => x.label === FALLBACK_EXPRESSION)?.path;
const noSprites = sprites.length === 0;
if (expressionImage.length > 0) {
if (name == spriteFolderName) {
await validateImages(spriteFolderName, true);
setExpressionOverrideHtml(true); // <= force clear expression override input
const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
const path = currentSpritePath || defaultSpritePath || '';
const img = expressionImage.find('img');
await setImage(img, path);
}
expressionImage.toggleClass('hidden', noSprites);
} else {
const template = $('#expression-holder').clone();
template.attr('id', `expression-${avatar}`);
template.attr('data-avatar', avatar);
template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
$('#visual-novel-wrapper').append(template);
dragElement($(template[0]));
template.toggleClass('hidden', noSprites);
await setImage(template.find('img'), defaultSpritePath || '');
const fadeInPromise = new Promise(resolve => {
template.fadeIn(250, () => resolve());
});
createCharacterPromises.push(fadeInPromise);
const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
setSpritePromises.push(setSpritePromise);
}
}
await Promise.allSettled(createCharacterPromises);
return setSpritePromises;
}
async function visualNovelUpdateLayers(container) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique);
const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x));
const layerIndices = filteredMembers.slice().sort((a, b) => {
const aRecentIndex = recentMessages.indexOf(a);
const bRecentIndex = recentMessages.indexOf(b);
const aFilteredIndex = filteredMembers.indexOf(a);
const bFilteredIndex = filteredMembers.indexOf(b);
if (aRecentIndex !== -1 && bRecentIndex !== -1) {
return bRecentIndex - aRecentIndex;
} else if (aRecentIndex !== -1) {
return 1;
} else if (bRecentIndex !== -1) {
return -1;
} else {
return aFilteredIndex - bFilteredIndex;
}
});
const setLayerIndicesPromises = [];
const sortFunction = (a, b) => {
const avatarA = $(a).data('avatar');
const avatarB = $(b).data('avatar');
const indexA = filteredMembers.indexOf(avatarA);
const indexB = filteredMembers.indexOf(avatarB);
return indexA - indexB;
};
const containerWidth = container.width();
const pivotalPoint = containerWidth * 0.5;
let images = $('.expression-holder');
let imagesWidth = [];
images.sort(sortFunction).each(function () {
imagesWidth.push($(this).width());
});
let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
let currentPosition = pivotalPoint - (totalWidth / 2);
if (totalWidth > containerWidth) {
let totalOverlap = totalWidth - containerWidth;
let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth);
let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap);
imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]);
currentPosition = 0; // Reset the initial position to 0
}
images.sort(sortFunction).each((index, current) => {
const element = $(current);
const elementID = element.attr('id')
// skip repositioning of dragged elements
if (element.data('dragged')
|| (power_user.movingUIState[elementID]
&& (typeof power_user.movingUIState[elementID] === 'object')
&& Object.keys(power_user.movingUIState[elementID]).length > 0)) {
loadMovingUIState()
//currentPosition += imagesWidth[index];
return;
}
const avatar = element.data('avatar');
const layerIndex = layerIndices.indexOf(avatar);
element.css('z-index', layerIndex);
element.show();
const promise = new Promise(resolve => {
element.animate({ left: currentPosition + 'px' }, 500, () => {
resolve();
});
});
currentPosition += imagesWidth[index];
setLayerIndicesPromises.push(promise);
});
await Promise.allSettled(setLayerIndicesPromises);
}
async function setLastMessageSprite(img, avatar, labels) {
const context = getContext();
const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
if (lastMessage) {
const text = lastMessage.mes || '';
2023-09-07 16:52:37 +02:00
const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name);
2023-07-20 19:32:15 +02:00
const sprites = spriteCache[spriteFolderName] || [];
const label = await getExpressionLabel(text);
const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
if (path) {
setImage(img, path);
}
}
}
async function setImage(img, path) {
// Cohee: If something goes wrong, uncomment this to return to the old behavior
/*
img.attr('src', path);
img.removeClass('default');
img.off('error');
img.on('error', function () {
console.debug('Error loading image', path);
$(this).off('error');
$(this).attr('src', '');
});
*/
return new Promise(resolve => {
const prevExpressionSrc = img.attr('src');
const expressionClone = img.clone();
const originalId = img.attr('id');
//only swap expressions when necessary
if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
//clone expression
expressionClone.addClass('expression-clone')
//make invisible and remove id to prevent double ids
//must be made invisible to start because they share the same Z-index
expressionClone.attr('id', '').css({ opacity: 0 });
//add new sprite path to clone src
expressionClone.attr('src', path);
//add invisible clone to html
expressionClone.appendTo(img.parent());
const duration = 200;
//add animation flags to both images
//to prevent multiple expression changes happening simultaneously
img.addClass('expression-animating');
// Set the parent container's min width and height before running the transition
const imgWidth = img.width();
const imgHeight = img.height();
const expressionHolder = img.parent();
expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
//position absolute prevent the original from jumping around during transition
2023-08-25 01:07:59 +02:00
img.css('position', 'absolute').width(imgWidth).height(imgHeight);
2023-07-20 19:32:15 +02:00
expressionClone.addClass('expression-animating');
//fade the clone in
expressionClone.css({
opacity: 0
}).animate({
opacity: 1
}, duration)
//when finshed fading in clone, fade out the original
.promise().done(function () {
img.animate({
opacity: 0
}, duration);
//remove old expression
img.remove();
//replace ID so it becomes the new 'original' expression for next change
expressionClone.attr('id', originalId);
expressionClone.removeClass('expression-animating');
// Reset the expression holder min height and width
expressionHolder.css('min-width', 100);
expressionHolder.css('min-height', 100);
resolve();
});
expressionClone.removeClass('expression-clone');
expressionClone.removeClass('default');
expressionClone.off('error');
expressionClone.on('error', function () {
console.debug('Expression image error', path);
2023-07-20 19:32:15 +02:00
$(this).attr('src', '');
$(this).off('error');
resolve();
});
} else {
resolve();
}
});
}
function onExpressionsShowDefaultInput() {
const value = $(this).prop('checked');
extension_settings.expressions.showDefault = value;
saveSettingsDebounced();
const existingImageSrc = $('img.expression').prop('src');
if (existingImageSrc !== undefined) { //if we have an image in src
if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default)
$('img.expression').prop('src', ''); //remove it
lastMessage = null;
}
if (value) {
lastMessage = null;
}
}
}
async function unloadLiveChar() {
if (!modules.includes('talkinghead')) {
console.debug('talkinghead module is disabled');
return;
}
try {
const url = new URL(getApiUrl());
2023-08-10 23:52:14 +02:00
url.pathname = '/api/talkinghead/unload';
2023-08-04 02:27:01 +02:00
const loadResponse = await doExtrasFetch(url);
if (!loadResponse.ok) {
throw new Error(loadResponse.statusText);
}
const loadResponseText = await loadResponse.text();
2023-08-04 07:54:01 +02:00
//console.log(`Response: ${loadResponseText}`);
} catch (error) {
2023-08-04 07:54:01 +02:00
//console.error(`Error unloading - ${error}`);
}
}
2023-08-01 22:57:04 +02:00
async function loadLiveChar() {
2023-08-10 23:52:14 +02:00
if (!modules.includes('talkinghead')) {
console.debug('talkinghead module is disabled');
return;
}
const spriteFolderName = getSpriteFolderName();
2023-08-01 22:57:04 +02:00
2023-08-10 23:52:14 +02:00
const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
2023-08-01 22:57:04 +02:00
try {
2023-08-10 23:52:14 +02:00
const spriteResponse = await fetch(talkingheadPath);
2023-08-01 22:57:04 +02:00
if (!spriteResponse.ok) {
throw new Error(spriteResponse.statusText);
}
const spriteBlob = await spriteResponse.blob();
2023-08-10 23:52:14 +02:00
const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
2023-08-01 22:57:04 +02:00
const formData = new FormData();
formData.append('file', spriteFile);
const url = new URL(getApiUrl());
2023-08-10 23:52:14 +02:00
url.pathname = '/api/talkinghead/load';
2023-08-01 22:57:04 +02:00
const loadResponse = await doExtrasFetch(url, {
method: 'POST',
body: formData,
});
if (!loadResponse.ok) {
throw new Error(loadResponse.statusText);
}
const loadResponseText = await loadResponse.text();
2023-08-10 23:52:14 +02:00
console.log(`Load talkinghead response: ${loadResponseText}`);
2023-08-01 22:57:04 +02:00
} catch (error) {
2023-08-10 23:52:14 +02:00
console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
2023-08-01 22:57:04 +02:00
}
2023-07-31 07:52:30 +02:00
}
2023-08-03 12:05:21 +02:00
function handleImageChange() {
2023-07-31 07:52:30 +02:00
const imgElement = document.querySelector('img#expression-image.expression');
if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
2023-07-31 07:52:30 +02:00
console.log("Cannot find addExpressionImage()");
return;
}
if (extension_settings.expressions.talkinghead && !extension_settings.expressions.local) {
2023-07-31 07:52:30 +02:00
// Method get IP of endpoint
2023-08-10 23:52:14 +02:00
const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
$('#expression-holder').css({ display: '' });
2023-08-10 23:52:14 +02:00
if (imgElement.src !== talkingheadResultFeedSrc) {
2023-08-03 12:05:21 +02:00
const expressionImageElement = document.querySelector('.expression_list_image');
if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
2023-08-03 12:05:21 +02:00
doExtrasFetch(expressionImageElement.src, {
method: 'HEAD',
2023-07-31 19:56:05 +02:00
})
2023-08-03 12:05:21 +02:00
.then(response => {
if (response.ok) {
2023-08-10 23:52:14 +02:00
imgElement.src = talkingheadResultFeedSrc;
2023-08-03 12:05:21 +02:00
}
})
.catch(error => {
console.error(error); // Log the error if necessary
});
}
2023-07-31 07:52:30 +02:00
}
2023-08-03 12:05:21 +02:00
} else {
2023-08-03 12:23:15 +02:00
imgElement.src = ""; //remove incase char doesnt have expressions
setExpression(getContext().name2, FALLBACK_EXPRESSION, true);
2023-07-31 07:52:30 +02:00
}
}
2023-07-20 19:32:15 +02:00
async function moduleWorker() {
const context = getContext();
// Hide and disable talkinghead while in local mode
$('#image_type_block').toggle(!extension_settings.expressions.local);
if (extension_settings.expressions.local && extension_settings.expressions.talkinghead) {
$('#image_type_toggle').prop('checked', false);
setTalkingHeadState(false);
}
2023-07-20 19:32:15 +02:00
// non-characters not supported
if (!context.groupId && (context.characterId === undefined || context.characterId === 'invalid-safety-id')) {
2023-07-20 19:32:15 +02:00
removeExpression();
return;
}
const vnMode = isVisualNovelMode();
const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible');
if (vnMode) {
$('#expression-wrapper').hide();
$('#visual-novel-wrapper').show();
} else {
$('#expression-wrapper').show();
$('#visual-novel-wrapper').hide();
}
const vnStateChanged = vnMode !== vnWrapperVisible;
if (vnStateChanged) {
lastMessage = null;
$('#visual-novel-wrapper').empty();
$("#expression-holder").css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' });
}
const currentLastMessage = getLastCharacterMessage();
let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
2023-07-20 19:32:15 +02:00
// character has no expressions or it is not loaded
if (Object.keys(spriteCache).length === 0) {
await validateImages(spriteFolderName);
lastCharacter = context.groupId || context.characterId;
}
const offlineMode = $('.expression_settings .offline_mode');
if (!modules.includes('classify') && !extension_settings.expressions.local) {
$('#open_chat_expressions').show();
$('#no_chat_expressions').hide();
2023-07-20 19:32:15 +02:00
offlineMode.css('display', 'block');
lastCharacter = context.groupId || context.characterId;
if (context.groupId) {
await validateImages(spriteFolderName, true);
await forceUpdateVisualNovelMode();
}
return;
}
else {
// force reload expressions list on connect to API
if (offlineMode.is(':visible')) {
expressionsList = null;
spriteCache = {};
expressionsList = await getExpressionsList();
await validateImages(spriteFolderName, true);
await forceUpdateVisualNovelMode();
}
2023-10-08 10:22:48 +02:00
if (context.groupId && !Array.isArray(spriteCache[spriteFolderName])) {
await validateImages(spriteFolderName, true);
await forceUpdateVisualNovelMode();
}
2023-07-20 19:32:15 +02:00
offlineMode.css('display', 'none');
}
// Don't bother classifying if current char has no sprites and no default expressions are enabled
2023-09-21 20:15:05 +02:00
if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
return;
}
2023-07-20 19:32:15 +02:00
// check if last message changed
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
&& lastMessage === currentLastMessage.mes) {
return;
}
// API is busy
if (inApiCall) {
console.debug('Classification API is busy');
2023-07-20 19:32:15 +02:00
return;
}
// Throttle classification requests during streaming
2023-10-08 10:22:48 +02:00
if (!context.groupId && context.streamingProcessor && !context.streamingProcessor.isFinished) {
const now = Date.now();
const timeSinceLastServerResponse = now - lastServerResponseTime;
if (timeSinceLastServerResponse < STREAMING_UPDATE_INTERVAL) {
console.log('Streaming in progress: throttling expression update. Next update at ' + new Date(lastServerResponseTime + STREAMING_UPDATE_INTERVAL));
return;
}
}
2023-07-20 19:32:15 +02:00
try {
inApiCall = true;
let expression = await getExpressionLabel(currentLastMessage.mes);
// If we're not already overriding the folder name, account for group chats.
if (spriteFolderName === currentLastMessage.name && !context.groupId) {
spriteFolderName = context.name2;
}
const force = !!context.groupId;
// Character won't be angry on you for swiping
if (currentLastMessage.mes == '...' && expressionsList.includes(FALLBACK_EXPRESSION)) {
expression = FALLBACK_EXPRESSION;
}
await sendExpressionCall(spriteFolderName, expression, force, vnMode);
}
catch (error) {
console.log(error);
}
finally {
inApiCall = false;
lastCharacter = context.groupId || context.characterId;
lastMessage = currentLastMessage.mes;
lastServerResponseTime = Date.now();
2023-07-20 19:32:15 +02:00
}
}
async function talkingHeadCheck() {
let spriteFolderName = getSpriteFolderName();
2023-08-04 07:54:01 +02:00
try {
await validateImages(spriteFolderName);
2023-08-10 23:52:14 +02:00
let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
let talkingheadPath_f = talkingheadObj ? talkingheadObj.path : null;
2023-08-04 07:54:01 +02:00
2023-08-10 23:55:05 +02:00
if (talkingheadPath_f != null) {
2023-08-10 23:52:14 +02:00
//console.log("talkingheadPath_f " + talkingheadPath_f);
2023-08-04 07:54:01 +02:00
return true;
2023-08-10 11:42:52 +02:00
} else {
2023-08-10 23:55:05 +02:00
//console.log("talkingheadPath_f is null");
2023-08-04 07:54:01 +02:00
unloadLiveChar();
return false;
}
} catch (err) {
return err;
}
}
function getSpriteFolderName(characterMessage = null, characterName = null) {
const context = getContext();
let spriteFolderName = characterName ?? context.name2;
const message = characterMessage ?? getLastCharacterMessage();
const avatarFileName = getFolderNameByMessage(message);
const expressionOverride = extension_settings.expressionOverrides.find(e => e.name == avatarFileName);
if (expressionOverride && expressionOverride.path) {
spriteFolderName = expressionOverride.path;
}
return spriteFolderName;
}
function setTalkingHeadState(switch_var) {
2023-08-10 23:52:14 +02:00
extension_settings.expressions.talkinghead = switch_var; // Store setting
2023-08-03 12:05:21 +02:00
saveSettingsDebounced();
if (extension_settings.expressions.local) {
return;
}
talkingHeadCheck().then(result => {
2023-08-04 07:54:01 +02:00
if (result) {
2023-08-10 23:52:14 +02:00
//console.log("talkinghead exists!");
2023-08-04 07:54:01 +02:00
2023-08-10 23:55:05 +02:00
if (extension_settings.expressions.talkinghead) {
2023-08-10 11:42:52 +02:00
loadLiveChar();
} else {
unloadLiveChar();
}
handleImageChange(); // Change image as needed
2023-08-04 07:54:01 +02:00
} else {
2023-08-10 23:52:14 +02:00
//console.log("talkinghead does not exist.");
2023-08-04 07:54:01 +02:00
}
});
2023-08-03 12:05:21 +02:00
}
function getFolderNameByMessage(message) {
2023-07-20 19:32:15 +02:00
const context = getContext();
let avatarPath = '';
if (context.groupId) {
avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar;
}
else if (context.characterId) {
avatarPath = getCharaFilename();
}
if (!avatarPath) {
return '';
}
const folderName = avatarPath.replace(/\.[^/.]+$/, "");
return folderName;
}
async function sendExpressionCall(name, expression, force, vnMode) {
if (!vnMode) {
vnMode = isVisualNovelMode();
}
if (vnMode) {
await updateVisualNovelMode(name, expression);
} else {
setExpression(name, expression, force);
}
}
2023-09-14 17:37:13 +02:00
async function setSpriteSetCommand(_, folder) {
if (!folder) {
console.log('Clearing sprite set');
folder = '';
}
2023-09-14 18:12:54 +02:00
if (folder.startsWith('/') || folder.startsWith('\\')) {
folder = folder.slice(1);
const currentLastMessage = getLastCharacterMessage();
folder = `${currentLastMessage.name}/${folder}`;
}
2023-09-14 17:37:13 +02:00
$("#expression_override").val(folder.trim());
onClickExpressionOverrideButton();
removeExpression();
moduleWorker();
}
async function setSpriteSlashCommand(_, spriteId) {
if (!spriteId) {
console.log('No sprite id provided');
return;
}
spriteId = spriteId.trim().toLowerCase();
2023-09-15 00:17:24 +02:00
const currentLastMessage = getLastCharacterMessage();
const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
await validateImages(spriteFolderName);
// Fuzzy search for sprite
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
const results = fuse.search(spriteId);
const spriteItem = results[0]?.item;
if (!spriteItem) {
console.log('No sprite found for search term ' + spriteId);
return;
}
const vnMode = isVisualNovelMode();
await sendExpressionCall(spriteFolderName, spriteItem.label, true, vnMode);
}
/**
* Processes the classification text to reduce the amount of text sent to the API.
* Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is.
* If the text is more than 300 characters, the first and last 150 characters are returned.
* The result is trimmed to the end of sentence.
* @param {string} text The text to process.
* @returns {string}
*/
function sampleClassifyText(text) {
if (!text) {
return text;
}
// Remove asterisks and quotes
let result = text.replace(/[\*\"]/g, '');
const SAMPLE_THRESHOLD = 500;
const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
if (text.length < SAMPLE_THRESHOLD) {
result = trimToEndSentence(result);
} else {
result = trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD)) + ' ' + trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD));
}
return result.trim();
}
2023-07-20 19:32:15 +02:00
async function getExpressionLabel(text) {
// Return if text is undefined, saving a costly fetch request
if ((!modules.includes('classify') && !extension_settings.expressions.local) || !text) {
2023-07-20 19:32:15 +02:00
return FALLBACK_EXPRESSION;
}
text = sampleClassifyText(text);
try {
if (extension_settings.expressions.local) {
// Local transformers pipeline
const apiResult = await fetch('/api/extra/classify', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ text: text }),
});
2023-07-20 19:32:15 +02:00
if (apiResult.ok) {
const data = await apiResult.json();
return data.classification[0].label;
}
} else {
// Extras
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify({ text: text }),
});
2023-07-20 19:32:15 +02:00
if (apiResult.ok) {
const data = await apiResult.json();
return data.classification[0].label;
}
}
} catch (error) {
console.log(error);
return FALLBACK_EXPRESSION;
2023-07-20 19:32:15 +02:00
}
}
function getLastCharacterMessage() {
const context = getContext();
const reversedChat = context.chat.slice().reverse();
for (let mes of reversedChat) {
if (mes.is_user || mes.is_system) {
continue;
}
return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar };
}
return { mes: '', name: null, original_avatar: null, force_avatar: null };
}
function removeExpression() {
lastMessage = null;
$('img.expression').off('error');
$('img.expression').prop('src', '');
$('img.expression').removeClass('default');
$('#open_chat_expressions').hide();
$('#no_chat_expressions').show();
2023-07-20 19:32:15 +02:00
}
async function validateImages(character, forceRedrawCached) {
if (!character) {
return;
}
const labels = await getExpressionsList();
if (spriteCache[character]) {
if (forceRedrawCached && $('#image_list').data('name') !== character) {
console.debug('force redrawing character sprites list')
drawSpritesList(character, labels, spriteCache[character]);
}
return;
}
const sprites = await getSpritesList(character);
let validExpressions = drawSpritesList(character, labels, sprites);
spriteCache[character] = validExpressions;
}
function drawSpritesList(character, labels, sprites) {
let validExpressions = [];
$('#no_chat_expressions').hide();
$('#open_chat_expressions').show();
2023-07-20 19:32:15 +02:00
$('#image_list').empty();
$('#image_list').data('name', character);
$('#image_list_header_name').text(character);
2023-07-20 19:32:15 +02:00
if (!Array.isArray(labels)) {
return [];
}
labels.sort().forEach((item) => {
const sprite = sprites.find(x => x.label == item);
2023-09-14 20:30:02 +02:00
const isCustom = extension_settings.expressions.custom.includes(item);
2023-07-20 19:32:15 +02:00
if (sprite) {
validExpressions.push(sprite);
2023-09-14 20:30:02 +02:00
$('#image_list').append(getListItem(item, sprite.path, 'success', isCustom));
2023-07-20 19:32:15 +02:00
}
else {
2023-09-14 20:30:02 +02:00
$('#image_list').append(getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom));
2023-07-20 19:32:15 +02:00
}
});
return validExpressions;
}
2023-09-14 20:30:02 +02:00
/**
* Renders a list item template for the expressions list.
* @param {string} item Expression name
* @param {string} imageSrc Path to image
* @param {'success' | 'failure'} textClass 'success' or 'failure'
* @param {boolean} isCustom If expression is added by user
* @returns {string} Rendered list item template
*/
function getListItem(item, imageSrc, textClass, isCustom) {
return renderExtensionTemplate(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });
2023-07-20 19:32:15 +02:00
}
async function getSpritesList(name) {
console.debug('getting sprites list');
try {
2023-09-16 17:03:31 +02:00
const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
2023-07-20 19:32:15 +02:00
let sprites = result.ok ? (await result.json()) : [];
return sprites;
}
catch (err) {
console.log(err);
return [];
}
}
2023-09-14 20:30:02 +02:00
function renderCustomExpressions() {
if (!Array.isArray(extension_settings.expressions.custom)) {
extension_settings.expressions.custom = [];
2023-07-20 19:32:15 +02:00
}
2023-09-14 20:30:02 +02:00
const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b));
$('#expression_custom').empty();
for (const expression of customExpressions) {
const option = document.createElement('option');
option.value = expression;
option.text = expression;
$('#expression_custom').append(option);
}
2023-09-14 20:41:30 +02:00
if (customExpressions.length === 0) {
$('#expression_custom').append('<option value="" disabled selected>[ No custom expressions ]</option>');
}
2023-09-14 20:30:02 +02:00
}
async function getExpressionsList() {
// Return cached list if available
2023-07-20 19:32:15 +02:00
if (Array.isArray(expressionsList)) {
return expressionsList;
}
2023-09-14 20:30:02 +02:00
/**
* Returns the list of expressions from the API or fallback in offline mode.
* @returns {Promise<string[]>}
*/
async function resolveExpressionsList() {
// get something for offline mode (default images)
if (!modules.includes('classify') && !extension_settings.expressions.local) {
return DEFAULT_EXPRESSIONS;
}
2023-07-20 19:32:15 +02:00
2023-09-14 20:30:02 +02:00
try {
if (extension_settings.expressions.local) {
const apiResult = await fetch('/api/extra/classify/labels', {
method: 'POST',
headers: getRequestHeaders(),
});
2023-09-14 20:30:02 +02:00
if (apiResult.ok) {
const data = await apiResult.json();
expressionsList = data.labels;
return expressionsList;
}
} else {
const url = new URL(getApiUrl());
url.pathname = '/api/classify/labels';
2023-09-14 20:30:02 +02:00
const apiResult = await doExtrasFetch(url, {
method: 'GET',
headers: { 'Bypass-Tunnel-Reminder': 'bypass' },
});
2023-07-20 19:32:15 +02:00
2023-09-14 20:30:02 +02:00
if (apiResult.ok) {
2023-07-20 19:32:15 +02:00
2023-09-14 20:30:02 +02:00
const data = await apiResult.json();
expressionsList = data.labels;
return expressionsList;
}
}
2023-07-20 19:32:15 +02:00
}
2023-09-14 20:30:02 +02:00
catch (error) {
console.log(error);
return [];
}
2023-07-20 19:32:15 +02:00
}
2023-09-14 20:30:02 +02:00
const result = await resolveExpressionsList();
result.push(...extension_settings.expressions.custom);
return result;
2023-07-20 19:32:15 +02:00
}
async function setExpression(character, expression, force) {
if (extension_settings.expressions.local || !extension_settings.expressions.talkinghead) {
2023-07-31 19:56:05 +02:00
console.debug('entered setExpressions');
await validateImages(character);
const img = $('img.expression');
const prevExpressionSrc = img.attr('src');
const expressionClone = img.clone()
2023-07-20 19:32:15 +02:00
2023-07-31 19:56:05 +02:00
const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
console.debug('checking for expression images to show..');
if (sprite) {
console.debug('setting expression from character images folder');
2023-07-20 19:32:15 +02:00
2023-07-31 19:56:05 +02:00
if (force && isVisualNovelMode()) {
const context = getContext();
const group = context.groups.find(x => x.id === context.groupId);
2023-07-20 19:32:15 +02:00
2023-07-31 19:56:05 +02:00
for (const member of group.members) {
const groupMember = context.characters.find(x => x.avatar === member);
2023-07-20 19:32:15 +02:00
2023-07-31 19:56:05 +02:00
if (!groupMember) {
continue;
}
2023-07-20 19:32:15 +02:00
2023-07-31 19:56:05 +02:00
if (groupMember.name == character) {
await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
return;
}
2023-07-20 19:32:15 +02:00
}
}
2023-07-31 19:56:05 +02:00
//only swap expressions when necessary
if (prevExpressionSrc !== sprite.path
&& !img.hasClass('expression-animating')) {
//clone expression
expressionClone.addClass('expression-clone')
//make invisible and remove id to prevent double ids
//must be made invisible to start because they share the same Z-index
expressionClone.attr('id', '').css({ opacity: 0 });
//add new sprite path to clone src
expressionClone.attr('src', sprite.path);
//add invisible clone to html
expressionClone.appendTo($("#expression-holder"))
const duration = 200;
//add animation flags to both images
//to prevent multiple expression changes happening simultaneously
img.addClass('expression-animating');
// Set the parent container's min width and height before running the transition
const imgWidth = img.width();
const imgHeight = img.height();
const expressionHolder = img.parent();
expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
//position absolute prevent the original from jumping around during transition
2023-08-25 01:07:59 +02:00
img.css('position', 'absolute').width(imgWidth).height(imgHeight);
2023-07-31 19:56:05 +02:00
expressionClone.addClass('expression-animating');
//fade the clone in
expressionClone.css({
opacity: 0
}).animate({
opacity: 1
}, duration)
//when finshed fading in clone, fade out the original
.promise().done(function () {
img.animate({
opacity: 0
}, duration);
//remove old expression
img.remove();
//replace ID so it becomes the new 'original' expression for next change
expressionClone.attr('id', 'expression-image');
expressionClone.removeClass('expression-animating');
// Reset the expression holder min height and width
expressionHolder.css('min-width', 100);
expressionHolder.css('min-height', 100);
});
expressionClone.removeClass('expression-clone');
expressionClone.removeClass('default');
expressionClone.off('error');
expressionClone.on('error', function () {
console.debug('Expression image error', sprite.path);
$(this).attr('src', '');
$(this).off('error');
if (force && extension_settings.expressions.showDefault) {
setDefault();
}
2023-07-20 19:32:15 +02:00
});
2023-08-10 11:47:56 +02:00
}
}
else {
if (extension_settings.expressions.showDefault) {
setDefault();
2023-07-31 07:52:30 +02:00
}
2023-07-20 19:32:15 +02:00
}
2023-07-31 19:56:05 +02:00
function setDefault() {
console.debug('setting default');
const defImgUrl = `/img/default-expressions/${expression}.png`;
//console.log(defImgUrl);
img.attr('src', defImgUrl);
img.addClass('default');
}
document.getElementById("expression-holder").style.display = '';
2023-08-03 12:05:21 +02:00
} else {
2023-08-04 07:54:01 +02:00
talkingHeadCheck().then(result => {
2023-08-04 07:54:01 +02:00
if (result) {
2023-08-10 11:42:52 +02:00
// Find the <img> element with id="expression-image" and class="expression"
const imgElement = document.querySelector('img#expression-image.expression');
//console.log("searching");
if (imgElement && imgElement instanceof HTMLImageElement) {
2023-08-10 11:42:52 +02:00
//console.log("setting value");
2023-08-10 23:55:05 +02:00
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
2023-08-10 11:42:52 +02:00
}
2023-08-04 07:54:01 +02:00
} else {
//console.log("The fetch failed!");
}
2023-08-04 07:54:01 +02:00
});
2023-08-25 22:48:59 +02:00
2023-07-31 07:52:30 +02:00
}
2023-07-31 19:56:05 +02:00
}
2023-07-20 19:32:15 +02:00
function onClickExpressionImage() {
const expression = $(this).attr('id');
setSpriteSlashCommand({}, expression);
2023-07-20 19:32:15 +02:00
}
2023-09-14 20:30:02 +02:00
async function onClickExpressionAddCustom() {
let expressionName = await callPopup(renderExtensionTemplate(MODULE_NAME, 'add-custom-expression'), 'input');
if (!expressionName) {
console.debug('No custom expression name provided');
return;
}
expressionName = expressionName.trim().toLowerCase();
// a-z, 0-9, dashes and underscores only
if (!/^[a-z0-9-_]+$/.test(expressionName)) {
toastr.info('Invalid custom expression name provided');
return;
}
// Check if expression name already exists in default expressions
if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
toastr.info('Expression name already exists');
return;
}
// Check if expression name already exists in custom expressions
if (extension_settings.expressions.custom.includes(expressionName)) {
toastr.info('Custom expression already exists');
return;
}
// Add custom expression into settings
extension_settings.expressions.custom.push(expressionName);
renderCustomExpressions();
saveSettingsDebounced();
// Force refresh sprites list
expressionsList = null;
spriteCache = {};
moduleWorker();
}
async function onClickExpressionRemoveCustom() {
const selectedExpression = $('#expression_custom').val();
if (!selectedExpression) {
console.debug('No custom expression selected');
return;
}
const confirmation = await callPopup(renderExtensionTemplate(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }), 'confirm');
if (!confirmation) {
console.debug('Custom expression removal cancelled');
return;
}
// Remove custom expression from settings
const index = extension_settings.expressions.custom.indexOf(selectedExpression);
extension_settings.expressions.custom.splice(index, 1);
renderCustomExpressions();
saveSettingsDebounced();
// Force refresh sprites list
expressionsList = null;
spriteCache = {};
moduleWorker();
}
2023-07-20 19:32:15 +02:00
async function handleFileUpload(url, formData) {
try {
const data = await jQuery.ajax({
type: "POST",
url: url,
data: formData,
beforeSend: function () { },
cache: false,
contentType: false,
processData: false,
});
// Refresh sprites list
const name = formData.get('name');
delete spriteCache[name];
await validateImages(name);
return data;
} catch (error) {
toastr.error('Failed to upload image');
}
}
async function onClickExpressionUpload(event) {
// Prevents the expression from being set
event.stopPropagation();
const id = $(this).closest('.expression_list_item').attr('id');
const name = $('#image_list').data('name');
const handleExpressionUploadChange = async (e) => {
const file = e.target.files[0];
if (!file) {
return;
}
const formData = new FormData();
formData.append('name', name);
formData.append('label', id);
formData.append('avatar', file);
await handleFileUpload('/api/sprites/upload', formData);
2023-07-20 19:32:15 +02:00
// Reset the input
e.target.form.reset();
};
$('#expression_upload')
.off('change')
.on('change', handleExpressionUploadChange)
.trigger('click');
}
async function onClickExpressionOverrideButton() {
const context = getContext();
const currentLastMessage = getLastCharacterMessage();
const avatarFileName = getFolderNameByMessage(currentLastMessage);
2023-07-20 19:32:15 +02:00
// If the avatar name couldn't be found, abort.
if (!avatarFileName) {
console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`);
return;
}
const overridePath = String($("#expression_override").val());
2023-07-20 19:32:15 +02:00
const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) =>
e.name == avatarFileName
);
// If the path is empty, delete the entry from overrides
if (overridePath === undefined || overridePath.length === 0) {
if (existingOverrideIndex === -1) {
return;
}
extension_settings.expressionOverrides.splice(existingOverrideIndex, 1);
console.debug(`Removed existing override for ${avatarFileName}`);
} else {
// Properly override objects and clear the sprite cache of the previously set names
const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex];
if (existingOverride) {
Object.assign(existingOverride, { path: overridePath });
delete spriteCache[existingOverride.name];
} else {
const characterOverride = { name: avatarFileName, path: overridePath };
extension_settings.expressionOverrides.push(characterOverride);
delete spriteCache[currentLastMessage.name];
}
console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`);
}
saveSettingsDebounced();
// Refresh sprites list. Assume the override path has been properly handled.
try {
$('#visual-novel-wrapper').empty();
await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
const expression = await getExpressionLabel(currentLastMessage.mes);
await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);
forceUpdateVisualNovelMode();
} catch (error) {
console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
}
}
async function onClickExpressionOverrideRemoveAllButton() {
// Remove all the overrided entries from sprite cache
for (const element of extension_settings.expressionOverrides) {
delete spriteCache[element.name];
}
extension_settings.expressionOverrides = [];
saveSettingsDebounced();
console.debug("All expression image overrides have been cleared.");
// Refresh sprites list to use the default name if applicable
try {
$('#visual-novel-wrapper').empty();
const currentLastMessage = getLastCharacterMessage();
await validateImages(currentLastMessage.name, true);
const expression = await getExpressionLabel(currentLastMessage.mes);
await sendExpressionCall(currentLastMessage.name, expression, true);
forceUpdateVisualNovelMode();
console.debug(extension_settings.expressionOverrides);
} catch (error) {
console.debug(`The current expression could not be set because of error: ${error}`);
}
}
async function onClickExpressionUploadPackButton() {
const name = $('#image_list').data('name');
const handleFileUploadChange = async (e) => {
const file = e.target.files[0];
if (!file) {
return;
}
const formData = new FormData();
formData.append('name', name);
formData.append('avatar', file);
const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
2023-07-20 19:32:15 +02:00
toastr.success(`Uploaded ${count} image(s) for ${name}`);
// Reset the input
e.target.form.reset();
};
$('#expression_upload_pack')
.off('change')
.on('change', handleFileUploadChange)
.trigger('click');
}
async function onClickExpressionDelete(event) {
// Prevents the expression from being set
event.stopPropagation();
const confirmation = await callPopup("<h3>Are you sure?</h3>Once deleted, it's gone forever!", 'confirm');
if (!confirmation) {
return;
}
const id = $(this).closest('.expression_list_item').attr('id');
const name = $('#image_list').data('name');
try {
await fetch('/api/sprites/delete', {
2023-07-20 19:32:15 +02:00
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name, label: id }),
});
} catch (error) {
toastr.error('Failed to delete image. Try again later.');
}
// Refresh sprites list
delete spriteCache[name];
await validateImages(name);
}
function setExpressionOverrideHtml(forceClear = false) {
const currentLastMessage = getLastCharacterMessage();
const avatarFileName = getFolderNameByMessage(currentLastMessage);
2023-07-20 19:32:15 +02:00
if (!avatarFileName) {
return;
}
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
e.name == avatarFileName
);
if (expressionOverride && expressionOverride.path) {
$("#expression_override").val(expressionOverride.path);
} else if (expressionOverride) {
delete extension_settings.expressionOverrides[expressionOverride.name];
}
if (forceClear && !expressionOverride) {
$("#expression_override").val("");
}
}
(function () {
function addExpressionImage() {
const html = `
<div id="expression-wrapper">
2023-08-04 02:27:01 +02:00
<div id="expression-holder" class="expression-holder" style="display:none;">
2023-07-20 19:32:15 +02:00
<div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div>
<img id="expression-image" class="expression">
</div>
</div>`;
$('body').append(html);
loadMovingUIState();
}
function addVisualNovelMode() {
const html = `
<div id="visual-novel-wrapper">
</div>`
const element = $(html);
element.hide();
$('body').append(element);
}
function addSettings() {
2023-08-25 22:48:59 +02:00
$('#extensions_settings').append(renderExtensionTemplate(MODULE_NAME, 'settings'));
2023-07-20 19:32:15 +02:00
$('#expression_override_button').on('click', onClickExpressionOverrideButton);
$('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
$('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
$('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
$('#expression_local').prop('checked', extension_settings.expressions.local).on('input', function () {
extension_settings.expressions.local = !!$(this).prop('checked');
moduleWorker();
saveSettingsDebounced();
});
2023-07-20 19:32:15 +02:00
$('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
$(document).on('dragstart', '.expression', (e) => {
e.preventDefault()
return false
})
$(document).on('click', '.expression_list_item', onClickExpressionImage);
$(document).on('click', '.expression_list_upload', onClickExpressionUpload);
$(document).on('click', '.expression_list_delete', onClickExpressionDelete);
$(window).on("resize", updateVisualNovelModeDebounced);
$("#open_chat_expressions").hide();
2023-07-31 07:52:30 +02:00
2023-08-03 12:05:21 +02:00
$('#image_type_toggle').on('click', function () {
if (this instanceof HTMLInputElement) {
setTalkingHeadState(this.checked);
}
2023-07-31 07:52:30 +02:00
});
2023-09-14 20:30:02 +02:00
renderCustomExpressions();
$('#expression_custom_add').on('click', onClickExpressionAddCustom);
$('#expression_custom_remove').on('click', onClickExpressionRemoveCustom);
2023-07-20 19:32:15 +02:00
}
addExpressionImage();
addVisualNovelMode();
addSettings();
const wrapper = new ModuleWorkerWrapper(moduleWorker);
const updateFunction = wrapper.update.bind(wrapper);
setInterval(updateFunction, UPDATE_INTERVAL);
moduleWorker();
dragElement($("#expression-holder"))
eventSource.on(event_types.CHAT_CHANGED, () => {
// character changed
removeExpression();
spriteCache = {};
//clear expression
let imgElement = document.getElementById('expression-image');
if (imgElement && imgElement instanceof HTMLImageElement) {
imgElement.src = "";
}
//set checkbox to global var
$('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
if (extension_settings.expressions.talkinghead) {
setTalkingHeadState(extension_settings.expressions.talkinghead);
}
2023-07-20 19:32:15 +02:00
setExpressionOverrideHtml();
if (isVisualNovelMode()) {
$('#visual-novel-wrapper').empty();
}
updateFunction();
2023-07-20 19:32:15 +02:00
});
eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
registerSlashCommand('sprite', setSpriteSlashCommand, ['emote'], '<span class="monospace">(spriteId)</span> force sets the sprite for the current character', true, true);
registerSlashCommand('spriteoverride', setSpriteSetCommand, ['costume'], '<span class="monospace">(optional folder)</span> sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.', true, true);
2023-07-20 19:32:15 +02:00
})();